优化原生结果编码传输与浏览器缓存,记录八路性能基线
原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。 补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。 验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
This commit is contained in:
1 parent
808c484f5b
commit
3bc4be3c06
55 files changed
+20522
-141
No files matched your search
@@ -9,6 +9,8 @@
|
||||
|
||||
网页当前只支持导入工程 JSON;XML 可从网页下载,用作后端输入。网页使用原生 BDF 和当前默认 `rtol=1e-8`,与 AME 内部积分器及历史数值设置不能视为相同。这里的参数对齐指模型物理参数与初值,不声称不同仿真器所有数值输出完全一致。
|
||||
|
||||
后续优化测试优先使用八路 corrected 工程;仅当八路跑不通且短期无法解决时,再使用四路。具体记录要求见 [优化验证约定](../../docs/standard/optimization-benchmark-model.md)。
|
||||
|
||||
2026-09-11 整理时,两份工程内容保持不变。其他 JSON/XML 已按用途移出本目录,移动前后字节数及 SHA256 相同:
|
||||
|
||||
| 原文件 | 当前路径 | 用途 |
|
||||
|
||||
Vendored
+325
@@ -0,0 +1,325 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<System name="skill-test" schemaVersion="3" unitSystem="SI">
|
||||
<Simulation tStart="0" tStop="10" sampleStep="0.02" maxStep="0.001" method="RK45"/>
|
||||
<Components>
|
||||
<Component id="amesim_pnch023_1" type="amesim_pnch023" modelVersion="0.1.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="cvol" value="0.057"/>
|
||||
<Parameter name="kth" value="0"/>
|
||||
<Parameter name="sth" value="0.1"/>
|
||||
<Parameter name="extemp" value="293.15"/>
|
||||
<Parameter name="p0" value="15300000"/>
|
||||
<Parameter name="T0" value="293.15"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnvo001_1" type="amesim_pnvo001" modelVersion="0.2.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="cq" value="0.45"/>
|
||||
<Parameter name="area0" value="0.0000785"/>
|
||||
<Parameter name="Cv" value="0.5"/>
|
||||
<Parameter name="Kv" value="0.4"/>
|
||||
<Parameter name="flowset" value="1"/>
|
||||
<Parameter name="opening0" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_step0_1" type="amesim_step0" modelVersion="0.1.0">
|
||||
<Parameter name="initial" value="0"/>
|
||||
<Parameter name="final" value="1"/>
|
||||
<Parameter name="time" value="0.04"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnch012_1" type="amesim_pnch012" modelVersion="0.1.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="cvol0" value="0.015"/>
|
||||
<Parameter name="kth" value="1500"/>
|
||||
<Parameter name="sth" value="0.7"/>
|
||||
<Parameter name="extemp" value="293.15"/>
|
||||
<Parameter name="p0" value="100000"/>
|
||||
<Parameter name="T0" value="293.15"/>
|
||||
<Parameter name="vol1" value="0"/>
|
||||
<Parameter name="vol2" value="0"/>
|
||||
<Parameter name="vol3" value="0"/>
|
||||
<Parameter name="vol4" value="0"/>
|
||||
<Parameter name="dvol1" value="0"/>
|
||||
<Parameter name="dvol2" value="0"/>
|
||||
<Parameter name="dvol3" value="0"/>
|
||||
<Parameter name="dvol4" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnpl01_2" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_pnpl01_3" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_pnpl01_4" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_ud00_1" type="amesim_ud00" modelVersion="0.2.0">
|
||||
<Parameter name="tstart" value="0"/>
|
||||
<Parameter name="start1" value="100000000000000000"/>
|
||||
<Parameter name="end1" value="100000000000000000"/>
|
||||
<Parameter name="t1" value="0.8"/>
|
||||
<Parameter name="start2" value="49000"/>
|
||||
<Parameter name="end2" value="49000"/>
|
||||
<Parameter name="t2" value="10"/>
|
||||
<Parameter name="start3" value="1"/>
|
||||
<Parameter name="end3" value="1"/>
|
||||
<Parameter name="t3" value="0"/>
|
||||
<Parameter name="start4" value="1"/>
|
||||
<Parameter name="end4" value="1"/>
|
||||
<Parameter name="t4" value="0"/>
|
||||
<Parameter name="start5" value="1"/>
|
||||
<Parameter name="end5" value="1"/>
|
||||
<Parameter name="t5" value="0"/>
|
||||
<Parameter name="start6" value="1"/>
|
||||
<Parameter name="end6" value="1"/>
|
||||
<Parameter name="t6" value="0"/>
|
||||
<Parameter name="start7" value="1"/>
|
||||
<Parameter name="end7" value="1"/>
|
||||
<Parameter name="t7" value="0"/>
|
||||
<Parameter name="start8" value="1"/>
|
||||
<Parameter name="end8" value="1"/>
|
||||
<Parameter name="t8" value="0"/>
|
||||
<Parameter name="nstages" value="2"/>
|
||||
<Parameter name="iscyclic" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_forc_1" type="amesim_forc" modelVersion="0.2.0">
|
||||
<Parameter name="direction" value="1"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnrp17_1" type="amesim_pnrp17" modelVersion="0.1.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="dp" value="0.2"/>
|
||||
<Parameter name="dr" value="0.001"/>
|
||||
<Parameter name="x0" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_helium_medium_1" type="amesim_helium_medium" modelVersion="0.1.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="property_model" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnch012_2" type="amesim_pnch012" modelVersion="0.1.0">
|
||||
<Parameter name="gi" value="1"/>
|
||||
<Parameter name="cvol0" value="0.015"/>
|
||||
<Parameter name="kth" value="1500"/>
|
||||
<Parameter name="sth" value="0.7"/>
|
||||
<Parameter name="extemp" value="293.15"/>
|
||||
<Parameter name="p0" value="100000"/>
|
||||
<Parameter name="T0" value="293.15"/>
|
||||
<Parameter name="vol1" value="0"/>
|
||||
<Parameter name="vol2" value="0"/>
|
||||
<Parameter name="vol3" value="0"/>
|
||||
<Parameter name="vol4" value="0"/>
|
||||
<Parameter name="dvol1" value="0"/>
|
||||
<Parameter name="dvol2" value="0"/>
|
||||
<Parameter name="dvol3" value="0"/>
|
||||
<Parameter name="dvol4" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_pnpl01_6" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_pnpl01_7" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_pnpl01_8" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_pnpl01_9" type="amesim_pnpl01" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_mecmas21_2" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||
<Parameter name="mass" value="50"/>
|
||||
<Parameter name="fstick" value="0"/>
|
||||
<Parameter name="fcoul" value="0"/>
|
||||
<Parameter name="rvisc" value="0"/>
|
||||
<Parameter name="wind" value="0"/>
|
||||
<Parameter name="dvel" value="0.000001"/>
|
||||
<Parameter name="restdvel" value="0.000001"/>
|
||||
<Parameter name="restcoeff" value="0.65"/>
|
||||
<Parameter name="astrib" value="0.001"/>
|
||||
<Parameter name="xmin" value="-1"/>
|
||||
<Parameter name="Kbmin" value="1000000000"/>
|
||||
<Parameter name="Dbmin" value="10000"/>
|
||||
<Parameter name="Pdmin" value="0.0001"/>
|
||||
<Parameter name="xmax" value="0.8"/>
|
||||
<Parameter name="Kbmax" value="1000000000"/>
|
||||
<Parameter name="Dbmax" value="10000"/>
|
||||
<Parameter name="Pdmax" value="0.0001"/>
|
||||
<Parameter name="theta" value="0"/>
|
||||
<Parameter name="useFriction" value="1"/>
|
||||
<Parameter name="stoptype" value="4"/>
|
||||
<Parameter name="discContactOption" value="1"/>
|
||||
<Parameter name="strib" value="1"/>
|
||||
<Parameter name="frictionType" value="1"/>
|
||||
<Parameter name="v0" value="0"/>
|
||||
<Parameter name="x0" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_f000_1" type="amesim_f000" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_f000_2" type="amesim_f000" modelVersion="0.1.0"/>
|
||||
<Component id="amesim_lstp00a_1" type="amesim_lstp00a" modelVersion="0.2.0">
|
||||
<Parameter name="na" value="10"/>
|
||||
<Parameter name="gap0" value="0"/>
|
||||
<Parameter name="kcont" value="100000000000"/>
|
||||
<Parameter name="G" value="85700000000"/>
|
||||
<Parameter name="sdiam" value="0.02"/>
|
||||
<Parameter name="wdiam" value="0.002"/>
|
||||
<Parameter name="rcont" value="100000000000"/>
|
||||
<Parameter name="Pdis" value="1e-7"/>
|
||||
<Parameter name="stiffmode" value="1"/>
|
||||
<Parameter name="discContactOption" value="1"/>
|
||||
</Component>
|
||||
<Component id="amesim_forc_2" type="amesim_forc" modelVersion="0.2.0">
|
||||
<Parameter name="direction" value="1"/>
|
||||
</Component>
|
||||
<Component id="amesim_ud00_2" type="amesim_ud00" modelVersion="0.2.0">
|
||||
<Parameter name="tstart" value="0"/>
|
||||
<Parameter name="start1" value="1000000000000"/>
|
||||
<Parameter name="end1" value="1000000000000"/>
|
||||
<Parameter name="t1" value="0.8"/>
|
||||
<Parameter name="start2" value="0"/>
|
||||
<Parameter name="end2" value="0"/>
|
||||
<Parameter name="t2" value="10"/>
|
||||
<Parameter name="start3" value="1"/>
|
||||
<Parameter name="end3" value="1"/>
|
||||
<Parameter name="t3" value="0"/>
|
||||
<Parameter name="start4" value="1"/>
|
||||
<Parameter name="end4" value="1"/>
|
||||
<Parameter name="t4" value="0"/>
|
||||
<Parameter name="start5" value="1"/>
|
||||
<Parameter name="end5" value="1"/>
|
||||
<Parameter name="t5" value="0"/>
|
||||
<Parameter name="start6" value="1"/>
|
||||
<Parameter name="end6" value="1"/>
|
||||
<Parameter name="t6" value="0"/>
|
||||
<Parameter name="start7" value="1"/>
|
||||
<Parameter name="end7" value="1"/>
|
||||
<Parameter name="t7" value="0"/>
|
||||
<Parameter name="start8" value="1"/>
|
||||
<Parameter name="end8" value="1"/>
|
||||
<Parameter name="t8" value="0"/>
|
||||
<Parameter name="nstages" value="2"/>
|
||||
<Parameter name="iscyclic" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_mecmas21_5" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||
<Parameter name="mass" value="170000"/>
|
||||
<Parameter name="fstick" value="0"/>
|
||||
<Parameter name="fcoul" value="0"/>
|
||||
<Parameter name="rvisc" value="0"/>
|
||||
<Parameter name="wind" value="0"/>
|
||||
<Parameter name="dvel" value="0.000001"/>
|
||||
<Parameter name="restdvel" value="0.000001"/>
|
||||
<Parameter name="restcoeff" value="0.65"/>
|
||||
<Parameter name="astrib" value="0.001"/>
|
||||
<Parameter name="xmin" value="0"/>
|
||||
<Parameter name="Kbmin" value="1000000000"/>
|
||||
<Parameter name="Dbmin" value="10000"/>
|
||||
<Parameter name="Pdmin" value="0.0001"/>
|
||||
<Parameter name="xmax" value="0.37"/>
|
||||
<Parameter name="Kbmax" value="1000000000"/>
|
||||
<Parameter name="Dbmax" value="10000"/>
|
||||
<Parameter name="Pdmax" value="0.0001"/>
|
||||
<Parameter name="theta" value="0"/>
|
||||
<Parameter name="useFriction" value="1"/>
|
||||
<Parameter name="stoptype" value="1"/>
|
||||
<Parameter name="discContactOption" value="1"/>
|
||||
<Parameter name="strib" value="1"/>
|
||||
<Parameter name="frictionType" value="1"/>
|
||||
<Parameter name="v0" value="0"/>
|
||||
<Parameter name="x0" value="0"/>
|
||||
</Component>
|
||||
<Component id="amesim_mecmas21_7" type="amesim_mecmas21" modelVersion="0.2.0">
|
||||
<Parameter name="mass" value="90000"/>
|
||||
<Parameter name="fstick" value="0"/>
|
||||
<Parameter name="fcoul" value="0"/>
|
||||
<Parameter name="rvisc" value="0"/>
|
||||
<Parameter name="wind" value="0"/>
|
||||
<Parameter name="dvel" value="0.000001"/>
|
||||
<Parameter name="restdvel" value="0.000001"/>
|
||||
<Parameter name="restcoeff" value="0.65"/>
|
||||
<Parameter name="astrib" value="0.001"/>
|
||||
<Parameter name="xmin" value="-0.72"/>
|
||||
<Parameter name="Kbmin" value="1000000000"/>
|
||||
<Parameter name="Dbmin" value="10000"/>
|
||||
<Parameter name="Pdmin" value="0.0001"/>
|
||||
<Parameter name="xmax" value="0"/>
|
||||
<Parameter name="Kbmax" value="1000000000"/>
|
||||
<Parameter name="Dbmax" value="10000"/>
|
||||
<Parameter name="Pdmax" value="0.0001"/>
|
||||
<Parameter name="theta" value="0"/>
|
||||
<Parameter name="useFriction" value="1"/>
|
||||
<Parameter name="stoptype" value="1"/>
|
||||
<Parameter name="discContactOption" value="1"/>
|
||||
<Parameter name="strib" value="1"/>
|
||||
<Parameter name="frictionType" value="1"/>
|
||||
<Parameter name="v0" value="0"/>
|
||||
<Parameter name="x0" value="0"/>
|
||||
</Component>
|
||||
</Components>
|
||||
<Connections>
|
||||
<Connection id="edge-amesim_pnch023_1-port_2-amesim_pnvo001_1-port_2-1786524999267">
|
||||
<Endpoint component="amesim_pnch023_1" port="port_2"/>
|
||||
<Endpoint component="amesim_pnvo001_1" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_pnvo001_1-port_3-amesim_pnch012_1-port_1-1786525009973">
|
||||
<Endpoint component="amesim_pnvo001_1" port="port_3"/>
|
||||
<Endpoint component="amesim_pnch012_1" port="port_1"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_2-port_1-amesim_pnch012_1-port_2-1786525035706-0">
|
||||
<Endpoint component="amesim_pnpl01_2" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_1" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_3-port_1-amesim_pnch012_1-port_3-1786525054054-0">
|
||||
<Endpoint component="amesim_pnpl01_3" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_1" port="port_3"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_4-port_1-amesim_pnch012_1-port_4-1786525061349-0">
|
||||
<Endpoint component="amesim_pnpl01_4" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_1" port="port_4"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_step0_1-out-amesim_pnvo001_1-res-1786525074494">
|
||||
<Endpoint component="amesim_step0_1" port="out"/>
|
||||
<Endpoint component="amesim_pnvo001_1" port="res"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_ud00_1-out-amesim_forc_1-res-1786525088412">
|
||||
<Endpoint component="amesim_ud00_1" port="out"/>
|
||||
<Endpoint component="amesim_forc_1" port="res"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_6-port_1-amesim_pnch023_1-port_1-1786525164428-0">
|
||||
<Endpoint component="amesim_pnpl01_6" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch023_1" port="port_1"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_7-port_1-amesim_pnch012_2-port_1-1786525181969-0">
|
||||
<Endpoint component="amesim_pnpl01_7" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_2" port="port_1"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_8-port_1-amesim_pnch012_2-port_4-1786525185493-0">
|
||||
<Endpoint component="amesim_pnpl01_8" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_2" port="port_4"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnpl01_9-port_1-amesim_pnch012_2-port_2-1786525188482-0">
|
||||
<Endpoint component="amesim_pnpl01_9" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_2" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_mecmas21_2-port_1-amesim_pnrp17_1-port_2-1786525203778">
|
||||
<Endpoint component="amesim_mecmas21_2" port="port_1"/>
|
||||
<Endpoint component="amesim_pnrp17_1" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_pnrp17_1-port_5-amesim_lstp00a_1-port_1-1786525213548">
|
||||
<Endpoint component="amesim_pnrp17_1" port="port_5"/>
|
||||
<Endpoint component="amesim_lstp00a_1" port="port_1"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_ud00_2-out-amesim_forc_2-res-1786525226761">
|
||||
<Endpoint component="amesim_ud00_2" port="out"/>
|
||||
<Endpoint component="amesim_forc_2" port="res"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_pnrp17_1-port_1-amesim_pnch012_2-port_3-1786525729832-1">
|
||||
<Endpoint component="amesim_pnrp17_1" port="port_1"/>
|
||||
<Endpoint component="amesim_pnch012_2" port="port_3"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_forc_1-port_2-amesim_mecmas21_7-port_2-1786525915163">
|
||||
<Endpoint component="amesim_forc_1" port="port_2"/>
|
||||
<Endpoint component="amesim_mecmas21_7" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_mecmas21_7-port_1-amesim_pnrp17_1-port_3-1786525916652">
|
||||
<Endpoint component="amesim_mecmas21_7" port="port_1"/>
|
||||
<Endpoint component="amesim_pnrp17_1" port="port_3"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_mecmas21_5-port_1-amesim_forc_2-port_2-1786525922010">
|
||||
<Endpoint component="amesim_mecmas21_5" port="port_1"/>
|
||||
<Endpoint component="amesim_forc_2" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-amesim_lstp00a_1-port_2-amesim_mecmas21_5-port_2-1786525924224">
|
||||
<Endpoint component="amesim_lstp00a_1" port="port_2"/>
|
||||
<Endpoint component="amesim_mecmas21_5" port="port_2"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_f000_2-port_1-amesim_pnrp17_1-port_4-1788429018340-0">
|
||||
<Endpoint component="amesim_f000_2" port="port_1"/>
|
||||
<Endpoint component="amesim_pnrp17_1" port="port_4"/>
|
||||
</Connection>
|
||||
<Connection id="edge-contact-amesim_f000_1-port_1-amesim_mecmas21_2-port_2-1788429022851-0">
|
||||
<Endpoint component="amesim_f000_1" port="port_1"/>
|
||||
<Endpoint component="amesim_mecmas21_2" port="port_2"/>
|
||||
</Connection>
|
||||
</Connections>
|
||||
</System>
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Serve the real app with opt-in, request-scoped stage measurements.
|
||||
|
||||
All C changes are sparse clocks in an isolated copy of runtime/main.c. Component
|
||||
kernels and the solver are copied unchanged. No production modules are edited.
|
||||
Use --plain for the uninstrumented HTTP/browser control. Output must be fresh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
LOCAL = threading.local()
|
||||
REQUEST = ContextVar('benchmark_request', default=None)
|
||||
|
||||
|
||||
def isolate_runtime(output):
|
||||
target = output/'native'
|
||||
shutil.copytree(ROOT/'native', target)
|
||||
p = target/'runtime/main.c'; source = p.read_text()
|
||||
source = '#include <time.h>\n' + source
|
||||
signature = next(line for line in source.splitlines() if line.startswith('static int write_result(NativeRun *r,'))
|
||||
source = source.replace(signature,
|
||||
'static double profile_projection_seconds, profile_json_write_seconds;\n'
|
||||
'static double profile_projection_cpu_seconds, profile_json_write_cpu_seconds;\n'
|
||||
+ signature + '\n'
|
||||
' double profile_output_start=native_wall_time();\n'
|
||||
' double profile_output_cpu_start=(double)clock()/CLOCKS_PER_SEC;')
|
||||
source = source.replace(' FILE *f=fopen(path,"wb");',
|
||||
' profile_projection_seconds=native_wall_time()-profile_output_start;\n'
|
||||
' profile_projection_cpu_seconds=(double)clock()/CLOCKS_PER_SEC-profile_output_cpu_start;\n'
|
||||
' double profile_write_start=native_wall_time();\n'
|
||||
' double profile_write_cpu_start=(double)clock()/CLOCKS_PER_SEC;\n FILE *f=fopen(path,"wb");')
|
||||
source = source.replace(' free(values); return ok;',
|
||||
' profile_json_write_seconds=native_wall_time()-profile_write_start;\n'
|
||||
' profile_json_write_cpu_seconds=(double)clock()/CLOCKS_PER_SEC-profile_write_cpu_start;\n'
|
||||
' free(values); return ok;')
|
||||
source = source.replace('int main(int argc, char **argv) {',
|
||||
'int main(int argc, char **argv) {\n double profile_main_start=native_wall_time();')
|
||||
source = source.replace(' native_solve(&r);',
|
||||
' double profile_solve_call_start=native_wall_time();\n native_solve(&r);\n'
|
||||
' double profile_solve_call_end=native_wall_time();')
|
||||
source = source.replace(' native_run_free(&r); return code;', r''' native_run_free(&r);
|
||||
fprintf(stderr,"{\"event\":\"native-stage-profile\",\"mainStartMonotonic\":%.17g,"
|
||||
"\"solveCallStartMonotonic\":%.17g,\"integrationStartMonotonic\":%.17g,"
|
||||
"\"integrationSeconds\":%.17g,\"solveCallEndMonotonic\":%.17g,"
|
||||
"\"argumentPreparationSeconds\":%.17g,\"initializationSeconds\":%.17g,"
|
||||
"\"finalSampleAndStatusSeconds\":%.17g,\"projectionSeconds\":%.17g,"
|
||||
"\"jsonWriteSeconds\":%.17g,\"projectionCpuSeconds\":%.17g,\"jsonWriteCpuSeconds\":%.17g,\"mainTotalSeconds\":%.17g}\n",
|
||||
profile_main_start,profile_solve_call_start,r.wall_start,r.solve_seconds,profile_solve_call_end,
|
||||
profile_solve_call_start-profile_main_start,r.wall_start-profile_solve_call_start,
|
||||
profile_solve_call_end-r.wall_start-r.solve_seconds,profile_projection_seconds,
|
||||
profile_json_write_seconds,profile_projection_cpu_seconds,profile_json_write_cpu_seconds,
|
||||
native_wall_time()-profile_main_start);
|
||||
return code;''')
|
||||
if source.count('native-stage-profile') != 1:
|
||||
raise ValueError('Unrecognized runtime layout')
|
||||
p.write_text(source)
|
||||
# The measurement copy must preserve all numerical files verbatim.
|
||||
for original in (ROOT/'native').rglob('*'):
|
||||
if original.is_file() and original.relative_to(ROOT/'native').as_posix() != 'runtime/main.c':
|
||||
assert original.read_bytes() == (target/original.relative_to(ROOT/'native')).read_bytes()
|
||||
return target
|
||||
|
||||
|
||||
class Profile:
|
||||
def __init__(self, output):
|
||||
self.output=output; self.records={}; self.trackers={}; self.results={}
|
||||
|
||||
def current(self):
|
||||
return getattr(LOCAL,'record',None) or REQUEST.get()
|
||||
|
||||
def record(self, record, name, start, end):
|
||||
record['spans'].append({'name':name,'startMs':(start-record['startNs'])/1e6,
|
||||
'endMs':(end-record['startNs'])/1e6,'seconds':(end-start)/1e9})
|
||||
|
||||
def wrap(self, function, name):
|
||||
@wraps(function)
|
||||
def measured(*args, **kwargs):
|
||||
record=self.current()
|
||||
if record is None: return function(*args, **kwargs)
|
||||
start=time.perf_counter_ns()
|
||||
try:return function(*args, **kwargs)
|
||||
finally:self.record(record,name,start,time.perf_counter_ns())
|
||||
return measured
|
||||
|
||||
def install(self, api, builder, runner):
|
||||
profile=self
|
||||
api.validate_system_xml_document=self.wrap(api.validate_system_xml_document,'xml_validation')
|
||||
api.compile_system_xml_network=self.wrap(api.compile_system_xml_network,'network_compilation')
|
||||
runner.compile_native_program=self.wrap(runner.compile_native_program,'c_generation')
|
||||
if hasattr(runner,'read_indexed_result'):
|
||||
runner.read_indexed_result=self.wrap(runner.read_indexed_result,'native_indexed_result_read')
|
||||
from app.simulation.native_codegen import transport
|
||||
class TransportJson:
|
||||
def __getattr__(self,key):return getattr(json,key)
|
||||
def loads(self,value,*args,**kwargs):
|
||||
return profile.wrap(json.loads,'native_result_metadata_json_parse')(value,*args,**kwargs)
|
||||
transport.json=TransportJson()
|
||||
api.serialize_result_parts=self.wrap(api.serialize_result_parts,'response_result_json_serialization')
|
||||
original_read_bytes=Path.read_bytes
|
||||
def read_bytes(path,*args,**kwargs):
|
||||
name='native_result_read_bytes' if path.name=='result.json' else 'native_result_index_read_bytes'
|
||||
if path.name in ('result.json','result-index.json') and self.current() is not None:
|
||||
return self.wrap(original_read_bytes,name)(path,*args,**kwargs)
|
||||
return original_read_bytes(path,*args,**kwargs)
|
||||
Path.read_bytes=read_bytes
|
||||
original_build=runner.build_native
|
||||
def isolated_build(program, **kwargs):
|
||||
kwargs['cache_dir']=self.output/'cache'
|
||||
result=original_build(program,**kwargs)
|
||||
rec=self.current()
|
||||
if rec is not None:
|
||||
rec['build']={'cacheHit':result.cache_hit,'buildKey':result.manifest['buildKey'],
|
||||
'reportedSeconds':result.seconds,'executable':str(result.executable)}
|
||||
return result
|
||||
runner.build_native=self.wrap(isolated_build,'native_build_or_cache_validation')
|
||||
builder.build_native=runner.build_native
|
||||
# Observe process creation and reaping without polling more often or
|
||||
# changing the production stderr-reader/cancellation loop.
|
||||
class RunnerSubprocess:
|
||||
def __getattr__(self, key): return getattr(subprocess, key)
|
||||
def Popen(self, *args, **kwargs):
|
||||
rec=profile.current()
|
||||
if rec is None: return subprocess.Popen(*args, **kwargs)
|
||||
start=time.perf_counter_ns()
|
||||
usage=resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||
process=subprocess.Popen(*args, **kwargs)
|
||||
profile.record(rec,'native_process_spawn',start,time.perf_counter_ns())
|
||||
rec['process']={'pid':process.pid,'command':list(args[0]),
|
||||
'startMs':(start-rec['startNs'])/1e6}
|
||||
original_poll,original_wait=process.poll,process.wait
|
||||
def observe(code):
|
||||
if code is not None and 'exitObservedMs' not in rec['process']:
|
||||
now=time.perf_counter_ns()
|
||||
after=resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||
rec['process'].update(exitCode=code,exitObservedMs=(now-rec['startNs'])/1e6,
|
||||
childrenUserCpuSeconds=after.ru_utime-usage.ru_utime,
|
||||
childrenSystemCpuSeconds=after.ru_stime-usage.ru_stime)
|
||||
profile.record(rec,'native_process_lifetime_observed',start,now)
|
||||
return code
|
||||
def poll(*a,**k):return observe(original_poll(*a,**k))
|
||||
def wait(*a,**k):return observe(original_wait(*a,**k))
|
||||
process.poll,process.wait=poll,wait
|
||||
return process
|
||||
runner.subprocess=RunnerSubprocess()
|
||||
original_execute=runner.execute_native
|
||||
def execute(*args, **kwargs):
|
||||
data=original_execute(*args,**kwargs)
|
||||
rec=self.current()
|
||||
if rec is not None:
|
||||
rec['native']={k:v for k,v in data.items() if k not in ('series','final','finalState')}
|
||||
series=data['series']
|
||||
rec['sampleCount']=series.sample_count if hasattr(series,'sample_count') else len(series.get('time',[]))
|
||||
if hasattr(series,'data'):rec['rawSeriesBytes']=len(series.data)
|
||||
started=time.perf_counter_ns()
|
||||
directory=self.output/'requests'/rec['id'];directory.mkdir(parents=True,exist_ok=True)
|
||||
source=kwargs['run_dir']/'result.json';target=directory/'native-result.json'
|
||||
try:os.link(source,target)
|
||||
except OSError:shutil.copyfile(source,target)
|
||||
shutil.copyfile(kwargs['run_dir']/'worker.log',directory/'worker.log')
|
||||
index=kwargs['run_dir']/'result-index.json'
|
||||
if index.exists():shutil.copyfile(index,directory/'result-index.json')
|
||||
self.record(rec,'profile_artifact_preservation',started,time.perf_counter_ns())
|
||||
return data
|
||||
runner.execute_native=self.wrap(execute,'native_execution_with_result_read')
|
||||
runner.simulate_native=self.wrap(runner.simulate_native,'native_orchestration_total')
|
||||
original_read=Path.read_text
|
||||
@wraps(original_read)
|
||||
def read_text(path,*args,**kwargs):
|
||||
if path.name=='result.json' and self.current() is not None:
|
||||
return self.wrap(original_read,'native_result_read_utf8')(path,*args,**kwargs)
|
||||
return original_read(path,*args,**kwargs)
|
||||
Path.read_text=read_text
|
||||
class RunnerJson:
|
||||
def __getattr__(self,key):return getattr(json,key)
|
||||
def loads(self,text,*args,**kwargs):
|
||||
rec=profile.current()
|
||||
if rec is not None and len(text)>100000:
|
||||
return profile.wrap(json.loads,'native_result_json_parse')(text,*args,**kwargs)
|
||||
value=json.loads(text,*args,**kwargs)
|
||||
if rec is not None and isinstance(value,dict) and value.get('event')=='native-stage-profile':
|
||||
rec['nativeStages']=value
|
||||
return value
|
||||
runner.json=RunnerJson()
|
||||
original_run=api.run_system_xml_simulation
|
||||
def run(xml,*args,**kwargs):
|
||||
tracker=kwargs.get('activity_tracker') or (args[2] if len(args)>2 else None)
|
||||
rec=self.trackers.get(id(tracker)) or REQUEST.get()
|
||||
if rec is None:return original_run(xml,*args,**kwargs)
|
||||
LOCAL.record=rec
|
||||
started=time.perf_counter_ns()
|
||||
try:
|
||||
result=original_run(xml,*args,**kwargs)
|
||||
self.results[id(result)]=rec
|
||||
rec['existingPerformance']=result['diagnostics'].get('performance')
|
||||
return result
|
||||
finally:
|
||||
self.record(rec,'simulation_worker_total',started,time.perf_counter_ns())
|
||||
LOCAL.record=None
|
||||
api.run_system_xml_simulation=run
|
||||
original_stream=api.simulation_event_stream
|
||||
def stream(xml,*,task=None,**kwargs):
|
||||
rec=self.records.get(task.simulation_id) if task else None
|
||||
if rec is not None:
|
||||
self.trackers[id(task.activity_tracker)]=rec
|
||||
rec['xmlSha256']=sha256(xml).hexdigest();rec['xmlBytes']=len(xml)
|
||||
directory=self.output/'requests'/rec['id'];directory.mkdir(parents=True,exist_ok=True)
|
||||
(directory/'input.xml').write_bytes(xml)
|
||||
yield from original_stream(xml,task=task,**kwargs)
|
||||
api.simulation_event_stream=stream
|
||||
class ApiJson:
|
||||
def __getattr__(self,key):return getattr(json,key)
|
||||
def dumps(self,value,*args,**kwargs):
|
||||
rec=profile.results.get(id(value.get('result'))) if isinstance(value,dict) and value.get('event')=='result' else None
|
||||
if rec is None:return json.dumps(value,*args,**kwargs)
|
||||
start=time.perf_counter_ns();result=json.dumps(value,*args,**kwargs)
|
||||
profile.record(rec,'response_result_json_serialization',start,time.perf_counter_ns())
|
||||
rec['resultJsonCharacters']=len(result)
|
||||
return result
|
||||
api.json=ApiJson()
|
||||
api.build_simulation_results_csv=self.wrap(api.build_simulation_results_csv,'csv_assembly')
|
||||
|
||||
def app(self, underlying):
|
||||
async def wrapped(scope,receive,send):
|
||||
if scope['type']!='http' or scope.get('path') not in ('/api/system-xml/simulate-stream','/api/simulation-results/csv'):
|
||||
return await underlying(scope,receive,send)
|
||||
headers=dict(scope.get('headers',[]));ident=headers.get(b'x-simulation-id',uuid4().hex.encode()).decode()
|
||||
if not all(c.isalnum() or c in '-_' for c in ident): raise ValueError('Invalid profiling request ID')
|
||||
rec={'id':ident,'path':scope['path'],'startNs':time.perf_counter_ns(),'spans':[],
|
||||
'responseBodyBytes':0,'responseSendAwaitSeconds':0.0}
|
||||
self.records[ident]=rec;token=REQUEST.set(rec)
|
||||
async def observed_receive():
|
||||
message=await receive()
|
||||
if message['type']=='http.request' and not message.get('more_body',False):
|
||||
rec['requestBodyCompleteMs']=(time.perf_counter_ns()-rec['startNs'])/1e6
|
||||
return message
|
||||
async def observed_send(message):
|
||||
start=time.perf_counter_ns()
|
||||
if message['type']=='http.response.start':
|
||||
rec['responseHeadersMs']=(start-rec['startNs'])/1e6;rec['httpStatus']=message['status']
|
||||
if message['type']=='http.response.body':
|
||||
rec['responseBodyBytes']+=len(message.get('body',b''))
|
||||
if len(message.get('body',b''))>100000:
|
||||
rec['largeResultBodySendStartMs']=(start-rec['startNs'])/1e6
|
||||
await send(message)
|
||||
rec['responseSendAwaitSeconds']+=(time.perf_counter_ns()-start)/1e9
|
||||
if message['type']=='http.response.body' and not message.get('more_body',False):
|
||||
rec['responseBodyCompleteMs']=(time.perf_counter_ns()-rec['startNs'])/1e6
|
||||
try:await underlying(scope,observed_receive,observed_send)
|
||||
finally:
|
||||
rec['httpTotalSeconds']=(time.perf_counter_ns()-rec['startNs'])/1e9
|
||||
REQUEST.reset(token)
|
||||
target=self.output/'requests'/ident;target.mkdir(parents=True,exist_ok=True)
|
||||
(target/'stages.json').write_text(json.dumps(rec,ensure_ascii=False,indent=2)+'\n')
|
||||
return wrapped
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output-dir',type=Path,required=True)
|
||||
parser.add_argument('--port',type=int,default=8012)
|
||||
parser.add_argument('--plain',action='store_true')
|
||||
parser.add_argument('--frontend-dist',type=Path,default=ROOT/'frontend/dist')
|
||||
args=parser.parse_args();out=args.output_dir.resolve()
|
||||
if out.exists():parser.error('Choose a fresh output directory')
|
||||
out.mkdir(parents=True)
|
||||
# Snapshot static assets so another workspace build cannot change a run.
|
||||
shutil.copytree(args.frontend_dist,out/'frontend')
|
||||
if not args.plain:os.environ['SIMULATIONAPP_PROFILE']='standard'
|
||||
import app.main as api
|
||||
from app.simulation.native_codegen import build as builder,runner
|
||||
api.FRONTEND_DIST_DIR=out/'frontend'
|
||||
profile=None
|
||||
if not args.plain:
|
||||
builder.NATIVE=isolate_runtime(out)
|
||||
profile=Profile(out);profile.install(api,builder,runner)
|
||||
metadata={'gitHead':subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(),
|
||||
'mode':'plain' if args.plain else 'profile','port':args.port,
|
||||
'python':sys.version,'platform':sys.platform,
|
||||
'frontendFiles':{str(p.relative_to(out/'frontend')):sha256(p.read_bytes()).hexdigest() for p in (out/'frontend').rglob('*') if p.is_file()},
|
||||
'productionKernelSha256':sha256((ROOT/'native/components/kernels.c').read_bytes()).hexdigest()}
|
||||
(out/'environment.json').write_text(json.dumps(metadata,indent=2)+'\n')
|
||||
import uvicorn
|
||||
uvicorn.run(profile.app(api.app) if profile else api.app,host='127.0.0.1',port=args.port)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Replay native result encoding in isolated C programs, without model evaluation.
|
||||
|
||||
Prepare only by default. Explicit --run builds a small C replay and serially
|
||||
runs one warmup and three measured real-file outputs per variant. --dev-null
|
||||
adds sink-only runs after real-file verification; these never stand in for I/O.
|
||||
|
||||
.venv/bin/python tests/manual/benchmark_native_result_encoding.py \
|
||||
--result-json test/web-cost-20260911/native-compute-profile/control/run-1/result.json \
|
||||
--output-dir test/c-result-encoding-20260911 --ryu-root /path/to/ryu --run
|
||||
|
||||
ryu-root must contain ryu/d2s.c and ryu/ryu.h (the ryu/ subdirectory itself is
|
||||
also accepted). No dependency is downloaded and no production source is edited.
|
||||
Every series cell, final scalar and finalState cell is encoded in C. Static
|
||||
metadata, JSON structure and escaped keys are prepared outside timing. Values
|
||||
are loaded contiguously before timing; this isolates decimal encoding/write
|
||||
cost, excluding projection and the production writer's strided matrix reads.
|
||||
Timers include fopen, buffer setup, formatting, write, flush and fclose, but
|
||||
not fsync durability. All real-file outputs are parsed and compared as complete
|
||||
binary64 values, including signed zero, outside the measured interval.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from array import array
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VARIANTS = ["fprintf-default", "fprintf-1m", "snprintf-64k", "ryu-64k"]
|
||||
C_SOURCE = r'''
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <time.h>
|
||||
#if HAVE_RYU
|
||||
#include "ryu/ryu.h"
|
||||
#endif
|
||||
#include "replay-layout.h"
|
||||
#define CHUNK (64u*1024u)
|
||||
typedef struct { FILE *f; char block[CHUNK]; size_t used; unsigned long long bytes; int failed; } Writer;
|
||||
static double wall_now(void) { struct timespec t; if(clock_gettime(CLOCK_MONOTONIC,&t))exit(72); return t.tv_sec+t.tv_nsec*1e-9; }
|
||||
static double cpu_now(void) { struct timespec t; if(clock_gettime(CLOCK_PROCESS_CPUTIME_ID,&t))exit(72); return t.tv_sec+t.tv_nsec*1e-9; }
|
||||
static void flush_block(Writer *w) {
|
||||
if(w->used && fwrite(w->block,1,w->used,w->f)!=w->used)w->failed=1;
|
||||
w->used=0;
|
||||
}
|
||||
static void block_bytes(Writer *w,const char *text,size_t n) {
|
||||
w->bytes+=n;
|
||||
while(n) {
|
||||
size_t left=CHUNK-w->used, part=n<left?n:left;
|
||||
memcpy(w->block+w->used,text,part); w->used+=part; text+=part; n-=part;
|
||||
if(w->used==CHUNK)flush_block(w);
|
||||
}
|
||||
}
|
||||
static void direct_bytes(Writer *w,const char *text,size_t n) {
|
||||
w->bytes+=n;
|
||||
if(fwrite(text,1,n,w->f)!=n)w->failed=1;
|
||||
}
|
||||
static void encode(Writer *w,const double *values,int mode) {
|
||||
for(size_t g=0;g<GROUP_COUNT;g++) {
|
||||
const Group *group=&groups[g];
|
||||
if(mode<2)direct_bytes(w,group->prefix,group->prefix_length);
|
||||
else block_bytes(w,group->prefix,group->prefix_length);
|
||||
for(size_t i=0;i<group->count;i++) {
|
||||
double value=values[group->offset+i];
|
||||
if(mode<2) {
|
||||
/* Same number/separator formatting call as native main.c. */
|
||||
int n=fprintf(w->f,"%s%.17g",i?",":"",value);
|
||||
if(n<0)w->failed=1; else w->bytes+=(unsigned)n;
|
||||
} else if(mode==2) {
|
||||
/* Format directly into the remaining batch buffer. */
|
||||
if(CHUNK-w->used<64)flush_block(w);
|
||||
int n=snprintf(w->block+w->used,CHUNK-w->used,"%s%.17g",i?",":"",value);
|
||||
if(n<0 || (size_t)n>=CHUNK-w->used){w->failed=1;return;}
|
||||
w->used+=(unsigned)n;w->bytes+=(unsigned)n;
|
||||
} else {
|
||||
#if HAVE_RYU
|
||||
if(CHUNK-w->used<64)flush_block(w);
|
||||
if(i){w->block[w->used++]=',';w->bytes++;}
|
||||
int n=d2s_buffered_n(value,w->block+w->used);
|
||||
if(n<1 || n>32){w->failed=1;return;}
|
||||
w->used+=(unsigned)n;w->bytes+=(unsigned)n;
|
||||
#else
|
||||
w->failed=1;return;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
if(mode<2)direct_bytes(w,tail,TAIL_LENGTH);
|
||||
else {block_bytes(w,tail,TAIL_LENGTH);flush_block(w);}
|
||||
}
|
||||
int main(int argc,char **argv) {
|
||||
if(argc!=4)return 64;
|
||||
int mode=-1;
|
||||
const char *names[]={"fprintf-default","fprintf-1m","snprintf-64k","ryu-64k"};
|
||||
for(int i=0;i<4;i++)if(!strcmp(argv[1],names[i]))mode=i;
|
||||
if(mode<0 || (mode==3 && !HAVE_RYU))return 64;
|
||||
if(sizeof(double)!=8 || sizeof(uint64_t)!=8)return 65;
|
||||
FILE *input=fopen(argv[2],"rb"); if(!input)return 66;
|
||||
double *values=malloc(VALUE_COUNT*sizeof(double));
|
||||
if(!values){fclose(input);return 67;}
|
||||
int loaded=fread(values,sizeof(double),VALUE_COUNT,input)==VALUE_COUNT && fgetc(input)==EOF && !ferror(input);
|
||||
if(fclose(input))loaded=0;
|
||||
if(!loaded){free(values);return 68;}
|
||||
for(size_t i=0;i<VALUE_COUNT;i++)if(!isfinite(values[i])){free(values);return 69;}
|
||||
Writer *writer=calloc(1,sizeof(Writer)); char *stdio_buffer=malloc(1024u*1024u);
|
||||
if(!writer || !stdio_buffer){free(values);free(writer);free(stdio_buffer);return 67;}
|
||||
/* Loading, allocation and input validation are intentionally outside timing. */
|
||||
double wall_start=wall_now(), cpu_start=cpu_now();
|
||||
writer->f=fopen(argv[3],"wb");
|
||||
if(!writer->f){free(values);free(writer);free(stdio_buffer);return 70;}
|
||||
if(mode==1 && setvbuf(writer->f,stdio_buffer,_IOFBF,1024u*1024u))writer->failed=1;
|
||||
/* Manual batch variants use identical unbuffered FILE sinks. */
|
||||
if(mode>=2 && setvbuf(writer->f,NULL,_IONBF,0))writer->failed=1;
|
||||
if(!writer->failed)encode(writer,values,mode);
|
||||
if(ferror(writer->f))writer->failed=1;
|
||||
if(fclose(writer->f))writer->failed=1;
|
||||
double cpu_seconds=cpu_now()-cpu_start, wall_seconds=wall_now()-wall_start;
|
||||
printf("{\"variant\":\"%s\",\"wallSeconds\":%.17g,\"cpuSeconds\":%.17g,\"encodedBytes\":%llu,\"valueCount\":%zu,\"success\":%s}\n",
|
||||
names[mode],wall_seconds,cpu_seconds,writer->bytes,(size_t)VALUE_COUNT,writer->failed?"false":"true");
|
||||
int code=writer->failed?71:0;
|
||||
free(values);free(stdio_buffer);free(writer);return code;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def read_json_numbers(path: Path) -> dict:
|
||||
# JSON's integer spelling -0 must retain the sign before conversion to double.
|
||||
return json.loads(path.read_bytes(), parse_int=lambda token: -0.0 if token == "-0" else int(token))
|
||||
|
||||
|
||||
def c_string(value: bytes) -> str:
|
||||
return '"' + ''.join(f"\\x{byte:02x}" for byte in value) + '"'
|
||||
|
||||
|
||||
def numeric_blocks(result: dict) -> list[tuple[str, list]]:
|
||||
blocks = [(f"series/{key}", value) for key, value in result["series"].items()]
|
||||
blocks.extend((f"final/{key}", [value]) for key, value in result["final"].items())
|
||||
blocks.append(("finalState", result["finalState"]))
|
||||
return blocks
|
||||
|
||||
|
||||
def prepare(args: argparse.Namespace) -> dict:
|
||||
output = args.output_dir.resolve()
|
||||
if not output.is_relative_to(ROOT / "test"):
|
||||
raise RuntimeError("Output must be beneath the repository's ignored test/ directory")
|
||||
if sys.byteorder != "little" or array('d').itemsize != 8 or not sys.platform.startswith("linux"):
|
||||
raise RuntimeError("This isolated replay currently requires Linux and little-endian binary64")
|
||||
result = read_json_numbers(args.result_json)
|
||||
if not isinstance(result.get("series"), dict) or not isinstance(result.get("final"), dict) or not isinstance(result.get("finalState"), list):
|
||||
raise RuntimeError("Input must be a complete native result.json")
|
||||
blocks = numeric_blocks(result)
|
||||
all_values = array('d')
|
||||
descriptors = []
|
||||
for name, values in blocks:
|
||||
if not isinstance(values, list):
|
||||
raise RuntimeError(f"Expected numeric array: {name}")
|
||||
for value in values:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise RuntimeError(f"Nonfinite or nonnumeric input: {name}")
|
||||
if isinstance(value, int) and int(float(value)) != value:
|
||||
raise RuntimeError(f"Integer does not fit exactly in binary64: {name}")
|
||||
descriptors.append({"name": name, "offset": len(all_values), "count": len(values)})
|
||||
all_values.extend(values)
|
||||
if not all_values:
|
||||
raise RuntimeError("No numeric output values")
|
||||
metadata = {key: value for key, value in result.items() if key not in {"series", "final", "finalState"}}
|
||||
pending = json.dumps(metadata, ensure_ascii=True, separators=(",", ":"), allow_nan=False)[:-1]
|
||||
pending += ("," if metadata else "") + '"series":{'
|
||||
prefixes = []
|
||||
first = True
|
||||
for key in result["series"]:
|
||||
pending += ("" if first else ",") + json.dumps(key, ensure_ascii=True) + ":["
|
||||
prefixes.append(pending.encode()); pending = "]"; first = False
|
||||
pending += '},"final":{'
|
||||
first = True
|
||||
for key in result["final"]:
|
||||
pending += ("" if first else ",") + json.dumps(key, ensure_ascii=True) + ":"
|
||||
prefixes.append(pending.encode()); pending = ""; first = False
|
||||
pending += '},"finalState":['
|
||||
prefixes.append(pending.encode())
|
||||
tail = b"]}\n"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
raw = output / "values.f64le"
|
||||
raw.write_bytes(all_values.tobytes())
|
||||
header = ["/* Generated test data: all numeric payload cells, no projection. */", "typedef struct { const char *prefix; size_t prefix_length, offset, count; } Group;", f"#define GROUP_COUNT {len(descriptors)}u", f"#define VALUE_COUNT {len(all_values)}u", "static const Group groups[]={"]
|
||||
for desc, prefix in zip(descriptors, prefixes, strict=True):
|
||||
header.append(f" {{{c_string(prefix)},{len(prefix)}u,{desc['offset']}u,{desc['count']}u}},")
|
||||
header += ["};", f"static const char tail[]={c_string(tail)};", f"#define TAIL_LENGTH {len(tail)}u"]
|
||||
(output / "replay-layout.h").write_text("\n".join(header) + "\n")
|
||||
(output / "replay.c").write_text(C_SOURCE)
|
||||
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
|
||||
if not compiler:
|
||||
raise RuntimeError("GCC is required")
|
||||
ryu = args.ryu_root.resolve() if args.ryu_root else None
|
||||
if ryu and not (ryu / "ryu/d2s.c").is_file() and (ryu / "d2s.c").is_file():
|
||||
ryu = ryu.parent
|
||||
if ryu and not all((ryu / name).is_file() for name in ("ryu/d2s.c", "ryu/ryu.h")):
|
||||
raise RuntimeError("--ryu-root must contain ryu/d2s.c and ryu/ryu.h")
|
||||
command = [compiler, "-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math", "-D_POSIX_C_SOURCE=200809L", f"-DHAVE_RYU={int(ryu is not None)}", "-I", str(output), str(output / "replay.c")]
|
||||
ryu_hashes = {}
|
||||
if ryu:
|
||||
command += ["-I", str(ryu), str(ryu / "ryu/d2s.c")]
|
||||
ryu_hashes = {str(p.relative_to(ryu)): digest(p) for p in sorted((ryu / "ryu").glob("*")) if p.is_file() and p.suffix in {".c", ".h"}}
|
||||
command += ["-lm", "-o", str(output / "replay")]
|
||||
prepared = {"sourceResult": str(args.result_json.resolve()), "sourceSha256": digest(args.result_json), "sourceBytes": args.result_json.stat().st_size, "rawValuesSha256": digest(raw), "rawValueBytes": raw.stat().st_size, "valueCount": len(all_values), "seriesColumns": len(result["series"]), "seriesValues": sum(len(v) for v in result["series"].values()), "finalValues": len(result["final"]), "finalStateValues": len(result["finalState"]), "blocks": descriptors, "variants": VARIANTS if ryu else VARIANTS[:3], "ryuRoot": str(ryu) if ryu else None, "ryuSourceHashes": ryu_hashes, "buildCommand": command, "compiler": subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True).stdout.splitlines()[0], "warmups": args.warmups, "repeats": args.repeats, "devNullRequested": args.dev_null, "precisionContract": "All finite payload values must decode to identical little-endian binary64 bytes, including signed zero. Shortest output may have different length/exponent spelling.", "timingContract": "C wall/process-CPU from before fopen through fclose, including buffer setup, all numeric payload formatting and writing. Excludes extraction, preload, allocation, static JSON framing preparation and verification. Ordinary files/page cache; no fsync durability. Contiguous replay does not reproduce production matrix strides or output projection. Block variants both use a 64KiB application buffer with unbuffered FILE sink."}
|
||||
write_json(output / "prepared.json", prepared)
|
||||
return prepared
|
||||
|
||||
|
||||
def verify_file(path: Path, expected: dict, raw: bytes, prepared: dict) -> dict:
|
||||
actual = read_json_numbers(path)
|
||||
# This catches missing columns, changed metadata, duplicates in array values,
|
||||
# order differences, and scalar value drift before the exact signed-zero pass.
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"Full result value/structure parity failed: {path}")
|
||||
actual_blocks = numeric_blocks(actual)
|
||||
if [name for name, _ in actual_blocks] != [d["name"] for d in prepared["blocks"]]:
|
||||
raise RuntimeError(f"Numeric block order differs: {path}")
|
||||
negative_zeroes = 0
|
||||
for (_, values), desc in zip(actual_blocks, prepared["blocks"], strict=True):
|
||||
binary = array('d', values).tobytes()
|
||||
start, end = desc["offset"] * 8, (desc["offset"] + desc["count"]) * 8
|
||||
if binary != raw[start:end]:
|
||||
raise RuntimeError(f"Binary64 parity failed at {desc['name']}: {path}")
|
||||
negative_zeroes += sum(value == 0 and math.copysign(1.0, value) < 0 for value in values)
|
||||
return {"fullResultParity": True, "allPayloadBinary64Parity": True, "checkedValues": prepared["valueCount"], "negativeZeroCount": negative_zeroes, "sha256": digest(path)}
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace, prepared: dict) -> None:
|
||||
output = args.output_dir.resolve()
|
||||
built = subprocess.run(prepared["buildCommand"], capture_output=True, text=True, timeout=120)
|
||||
(output / "build.log").write_text(built.stdout + built.stderr)
|
||||
if built.returncode:
|
||||
raise RuntimeError(f"Compilation failed: {output / 'build.log'}")
|
||||
expected = read_json_numbers(args.result_json)
|
||||
raw = (output / "values.f64le").read_bytes()
|
||||
rows = []
|
||||
for sink in (["file", "dev-null"] if args.dev_null else ["file"]):
|
||||
for index in range(-args.warmups, args.repeats):
|
||||
label = f"warmup-{index + args.warmups + 1}" if index < 0 else f"run-{index + 1}"
|
||||
for variant in prepared["variants"]:
|
||||
run = output / sink / variant / label
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
target = run / "result.json" if sink == "file" else Path("/dev/null")
|
||||
started = time.perf_counter()
|
||||
process = subprocess.run([str(output / "replay"), variant, str(output / "values.f64le"), str(target)], capture_output=True, text=True, timeout=120)
|
||||
process_wall = time.perf_counter() - started
|
||||
(run / "stdout.log").write_text(process.stdout)
|
||||
(run / "stderr.log").write_text(process.stderr)
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"Replay failed ({process.returncode}): {run}")
|
||||
record = json.loads(process.stdout)
|
||||
if record["success"] is not True:
|
||||
raise RuntimeError(f"Encoding reported failure: {run}")
|
||||
record.update(sink=sink, run=label, warmup=index < 0, processWallSeconds=process_wall)
|
||||
if sink == "file":
|
||||
if target.stat().st_size != record["encodedBytes"]:
|
||||
raise RuntimeError(f"Written byte count differs: {target}")
|
||||
record["verification"] = verify_file(target, expected, raw, prepared)
|
||||
else:
|
||||
record["verification"] = {"actualSinkFileReadback": False, "sameEncoderPassedRealFileReadback": True}
|
||||
write_json(run / "run.json", record)
|
||||
rows.append(record)
|
||||
print(f"{sink}/{variant}/{label}: wall={record['wallSeconds']:.6f}s cpu={record['cpuSeconds']:.6f}s bytes={record['encodedBytes']}", flush=True)
|
||||
medians = {}
|
||||
for sink in {row["sink"] for row in rows}:
|
||||
medians[sink] = {}
|
||||
for variant in prepared["variants"]:
|
||||
selected = [row for row in rows if row["sink"] == sink and row["variant"] == variant and not row["warmup"]]
|
||||
medians[sink][variant] = {key: statistics.median(row[key] for row in selected) for key in ("wallSeconds", "cpuSeconds", "encodedBytes")}
|
||||
baseline = medians[sink]["fprintf-default"]
|
||||
for data in medians[sink].values():
|
||||
data["wallReductionFractionVsDefault"] = 1 - data["wallSeconds"] / baseline["wallSeconds"]
|
||||
data["byteReductionFractionVsDefault"] = 1 - data["encodedBytes"] / baseline["encodedBytes"]
|
||||
write_json(output / "summary.json", {"prepared": prepared, "runs": rows, "medians": medians, "allRealFileBinary64Parity": True, "limitation": "This replay isolates formatting and ordinary file writes on preloaded contiguous doubles. It is not an end-to-end native/application speedup and excludes projection, strided output reads and durable storage flush. /dev/null metrics, if present, are separate sink-only observations."})
|
||||
print(f"Summary: {output / 'summary.json'}", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--result-json", required=True, type=Path)
|
||||
parser.add_argument("--output-dir", type=Path, default=ROOT / "test/c-result-encoding-20260911")
|
||||
parser.add_argument("--ryu-root", type=Path)
|
||||
parser.add_argument("--run", action="store_true")
|
||||
parser.add_argument("--dev-null", action="store_true")
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
args = parser.parse_args()
|
||||
if args.warmups < 0 or args.repeats < 1:
|
||||
parser.error("warmups must be nonnegative and repeats positive")
|
||||
prepared = prepare(args)
|
||||
print(f"Prepared {prepared['valueCount']} binary64 values and {len(prepared['variants'])} variants: {args.output_dir.resolve()}", flush=True)
|
||||
if args.run:
|
||||
execute(args, prepared)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,967 @@
|
||||
// Real production-page profiling. No route mocks, response cloning, or duplicate body parsing.
|
||||
// All stage timestamps use the active document's performance.now(). A reload starts a new axis.
|
||||
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const usage = `node tests/manual/browser_stage_profile.mjs --output DIR [--input tests/data/test-mql-8-corrected.json] [--url http://127.0.0.1:8011] [--runs 3 (0 for one smoke run)] [--mode both|profiled|control] [--deep] [--cpu-interval-us 1000] [--source-map-dir DIR] [--check]
|
||||
Offline only: node tests/manual/browser_stage_profile.mjs --summarize-cpu-only EXISTING_DIRECTORY [--source-map-dir DIR] [--check]
|
||||
Each mode runs one warmup followed by RUNS measured runs, sequentially. --check validates inputs without launching a browser.
|
||||
Optional --deep (alias --cpu-profile) records renderer-main-thread .cpuprofile diagnostics separately from ordinary endpoint timing.
|
||||
--source-map-dir accepts an offline hidden-source-map build only when its generated JS bytes exactly match served assets.
|
||||
Run with the repository Node 24 and Chromium runtime library environment. No application code is modified.`;
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help')) { console.log(usage); process.exit(0); }
|
||||
const options = { input: 'tests/data/test-mql-8-corrected.json', url: 'http://127.0.0.1:8011', runs: '3', mode: 'both', cpuIntervalUs: '1000' };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (['--deep', '--cpu-profile'].includes(args[i])) { options.deep = true; continue; }
|
||||
if (args[i] === '--check') { options.check = true; continue; }
|
||||
const cliKey = args[i].replace(/^--/, '');
|
||||
const key = ({ 'cpu-interval-us': 'cpuIntervalUs', 'source-map-dir': 'sourceMapDir', 'summarize-cpu-only': 'summarizeCpuOnly' })[cliKey] ?? cliKey;
|
||||
if (!['input', 'output', 'url', 'runs', 'mode', 'cpuIntervalUs', 'sourceMapDir', 'summarizeCpuOnly'].includes(key) || !args[i + 1]) throw new Error(usage);
|
||||
options[key] = args[++i];
|
||||
}
|
||||
if (!['both', 'profiled', 'control'].includes(options.mode) || !/^(0|[1-9]\d*)$/.test(options.runs)) throw new Error(usage);
|
||||
if (!/^\d+$/.test(options.cpuIntervalUs) || Number(options.cpuIntervalUs) < 100 || Number(options.cpuIntervalUs) > 100000) throw new Error('--cpu-interval-us must be between 100 and 100000.');
|
||||
if (options.sourceMapDir && !options.deep && !options.summarizeCpuOnly) throw new Error('--source-map-dir requires --deep or --summarize-cpu-only.');
|
||||
const sha = data => createHash('sha256').update(data).digest('hex');
|
||||
let inputText; let project; let curveNodeId;
|
||||
if (!options.summarizeCpuOnly) {
|
||||
inputText = await fs.readFile(options.input, 'utf8');
|
||||
project = JSON.parse(inputText);
|
||||
assert.equal(project.projectSchemaVersion, 1);
|
||||
assert.ok(project.nodes.length && project.edges.length);
|
||||
curveNodeId = project.nodes.find(n => n.id === 'amesim_pnl0002_10')?.id
|
||||
?? project.nodes.find(n => n.data.modelType === 'amesim_pnl0002')?.id;
|
||||
assert.ok(curveNodeId, 'A PNL0002 temperature component is required.');
|
||||
if (!options.check) {
|
||||
if (!options.output) throw new Error(usage);
|
||||
await fs.mkdir(options.output, { recursive: true });
|
||||
await fs.writeFile(path.join(options.output, 'input.json'), inputText);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialized by Playwright. Only the profiled mode wraps production APIs; control observes
|
||||
// clicks and DOM readiness, with storage-pointer polling at 16 ms resolution.
|
||||
function installStageObserver({ profiled }) {
|
||||
const storageKey = 'system-simulation-flow:latest-result';
|
||||
const nativeParse = JSON.parse;
|
||||
const data = { profiled, timeOrigin: performance.timeOrigin, marks: {}, requests: [], reads: [], parses: [],
|
||||
decodes: [], transactions: [], workers: [], downloads: [], longTasks: [], streamActive: false, activeRun: false };
|
||||
window.__stageProfile = data;
|
||||
const mark = (name, value = performance.now()) => {
|
||||
if (data.marks[name] === undefined) {
|
||||
data.marks[name] = value;
|
||||
performance.mark(`stage:${name}`, { startTime: value });
|
||||
}
|
||||
return value;
|
||||
};
|
||||
data.mark = mark;
|
||||
data.reset = () => {
|
||||
Object.assign(data, { marks: {}, requests: [], reads: [], parses: [], decodes: [], transactions: [], workers: [], runFailure: undefined,
|
||||
downloads: [], longTasks: [], streamActive: false, activeRun: true });
|
||||
performance.clearMarks();
|
||||
};
|
||||
data.armClick = (name, selector) => {
|
||||
const listener = event => {
|
||||
if (event.target instanceof Element && event.target.closest(selector)) {
|
||||
mark(name);
|
||||
document.removeEventListener('click', listener, true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', listener, true);
|
||||
};
|
||||
data.watchRunReady = () => {
|
||||
const previous = sessionStorage.getItem(storageKey);
|
||||
let observedBusy = false;
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
||||
function check() {
|
||||
if (data.marks.runClick === undefined) return;
|
||||
const failure = document.querySelector('.simulation-console-dock-progress.error, .simulation-console-progress.error');
|
||||
if (failure) { data.runFailure = failure.textContent; mark('runFailure'); observer.disconnect(); return; }
|
||||
const button = document.querySelector('button[aria-label="运行仿真"]');
|
||||
observedBusy ||= Boolean(button?.disabled || document.querySelector('.simulation-console-dock-progress.running, .simulation-console-progress.running'));
|
||||
const success = document.querySelector('.simulation-console-dock-progress.success, .simulation-console-progress.success');
|
||||
if (observedBusy && button && !button.disabled && success?.textContent.includes('仿真完成')) {
|
||||
mark('resultReadyDom');
|
||||
data.streamActive = false;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => mark('resultReadyPaintOpportunity')));
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
const poll = () => {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (raw && raw !== previous && nativeParse(raw).storage === 'indexeddb') {
|
||||
mark('indexedDbPointerObserved');
|
||||
} else setTimeout(poll, 16);
|
||||
};
|
||||
poll();
|
||||
};
|
||||
// DOM quiet is an operational threshold, not measured GPU work. No SVG serialization.
|
||||
data.watchDom = (name, selector, quietMs = 120) => {
|
||||
let target;
|
||||
let quietTimer;
|
||||
let quietObserver;
|
||||
const foundObserver = new MutationObserver(find);
|
||||
function find() {
|
||||
const next = document.querySelector(selector);
|
||||
if (!next || !next.getClientRects().length) return;
|
||||
target = next;
|
||||
mark(`${name}Dom`);
|
||||
foundObserver.disconnect();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => mark(`${name}PaintOpportunity`)));
|
||||
const settle = () => {
|
||||
clearTimeout(quietTimer);
|
||||
data.marks[`${name}LastMutation`] = performance.now();
|
||||
quietTimer = setTimeout(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
// A later mutation cancels the pending quiet interval as well as its timer.
|
||||
if (performance.now() - data.marks[`${name}LastMutation`] < quietMs) return;
|
||||
mark(`${name}Stable`);
|
||||
quietObserver.disconnect();
|
||||
})), quietMs);
|
||||
};
|
||||
quietObserver = new MutationObserver(settle);
|
||||
quietObserver.observe(target, { subtree: true, childList: true, attributes: true, characterData: true });
|
||||
settle();
|
||||
}
|
||||
foundObserver.observe(document, { subtree: true, childList: true, attributes: true });
|
||||
find();
|
||||
};
|
||||
data.watchImport = expectedName => {
|
||||
const previous = [...document.querySelectorAll('[data-entry-id]')].at(-1)?.getAttribute('data-entry-id');
|
||||
const fileInput = document.querySelector('input[type="file"][accept*=".json"]');
|
||||
fileInput.addEventListener('change', () => mark('importChange'), { once: true, capture: true });
|
||||
const observer = new MutationObserver(() => {
|
||||
if (data.marks.importChange === undefined) return;
|
||||
const entry = [...document.querySelectorAll('[data-entry-id]')].at(-1);
|
||||
if (entry?.getAttribute('data-entry-id') !== previous && entry?.textContent.includes('已导入工程') &&
|
||||
[...document.querySelectorAll('input')].some(input => input.value === expectedName)) {
|
||||
mark('importReadyDom');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => mark('importReadyPaintOpportunity')));
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document, { subtree: true, childList: true, characterData: true });
|
||||
};
|
||||
data.snapshot = () => ({ ...data, mark: undefined, reset: undefined, armClick: undefined, watchRunReady: undefined,
|
||||
watchDom: undefined, watchImport: undefined, snapshot: undefined, resources: performance.getEntriesByType('resource')
|
||||
.filter(e => /simulate-stream|simulation-results\/csv/.test(e.name))
|
||||
.map(e => ({ name: e.name, startTime: e.startTime, requestStart: e.requestStart, responseStart: e.responseStart,
|
||||
responseEnd: e.responseEnd, duration: e.duration, transferSize: e.transferSize,
|
||||
encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize })) });
|
||||
if (location.hash === '#/results' && sessionStorage.getItem(storageKey)) {
|
||||
data.watchDom('restoredResults', '.results-shell .results-system-panel');
|
||||
}
|
||||
if (!profiled) return;
|
||||
|
||||
const bodies = new WeakMap();
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function (...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : String(args[0]);
|
||||
const kind = url.includes('/api/system-xml/simulate-stream') ? 'simulation'
|
||||
: url.includes('/api/simulation-results/csv') ? 'csv' : null;
|
||||
if (!kind) return Reflect.apply(originalFetch, this, args);
|
||||
const row = { kind, fetchStart: performance.now(), simulationId: new Headers(args[1]?.headers ?? (args[0] instanceof Request ? args[0].headers : undefined)).get('X-Simulation-Id'), requestStringLength: typeof args[1]?.body === 'string' ? args[1].body.length : null };
|
||||
data.requests.push(row);
|
||||
if (kind === 'simulation') { mark('fetchStart', row.fetchStart); data.streamActive = true; }
|
||||
return Reflect.apply(originalFetch, this, args).then(response => {
|
||||
row.headers = performance.now(); row.status = response.status;
|
||||
if (kind === 'simulation') mark('headers', row.headers);
|
||||
if (response.body) bodies.set(response.body, row);
|
||||
return response;
|
||||
});
|
||||
};
|
||||
const originalGetReader = ReadableStream.prototype.getReader;
|
||||
ReadableStream.prototype.getReader = function (...args) {
|
||||
const reader = Reflect.apply(originalGetReader, this, args);
|
||||
const request = bodies.get(this);
|
||||
if (!request) return reader;
|
||||
const originalRead = reader.read;
|
||||
reader.read = function (...readArgs) {
|
||||
const row = { kind: request.kind, start: performance.now() };
|
||||
return Reflect.apply(originalRead, this, readArgs).then(value => {
|
||||
row.end = performance.now(); row.bytes = value.value?.byteLength ?? 0; row.done = value.done;
|
||||
data.reads.push(row);
|
||||
if (request.kind === 'simulation') {
|
||||
if (row.bytes) { mark('firstChunk', row.end); data.marks.lastChunk = row.end; }
|
||||
if (row.done) mark('streamEof', row.end);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
return reader;
|
||||
};
|
||||
JSON.parse = function (...args) {
|
||||
if (!data.streamActive) return Reflect.apply(nativeParse, this, args);
|
||||
const start = performance.now();
|
||||
const parsed = Reflect.apply(nativeParse, this, args);
|
||||
const end = performance.now();
|
||||
if (parsed && ['progress', 'result', 'error'].includes(parsed.event)) {
|
||||
data.parses.push({ event: parsed.event, phase: parsed.phase, start, end, characters: typeof args[0] === 'string' ? args[0].length : null });
|
||||
if (parsed.event === 'result') {
|
||||
mark('resultParseStart', start); mark('resultParseEnd', end);
|
||||
// This microtask is only a checkpoint after the current consumer continuation,
|
||||
// not a claim that all EOF/finally/publish/React work has completed.
|
||||
queueMicrotask(() => mark('resultConsumerMicrotaskCheckpoint'));
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const originalDecode = TextDecoder.prototype.decode;
|
||||
TextDecoder.prototype.decode = function (...args) {
|
||||
if (!data.streamActive) return Reflect.apply(originalDecode, this, args);
|
||||
const start = performance.now();
|
||||
const value = Reflect.apply(originalDecode, this, args);
|
||||
data.decodes.push({ start, end: performance.now(), bytes: args[0]?.byteLength ?? 0 });
|
||||
return value;
|
||||
};
|
||||
const originalTransaction = IDBDatabase.prototype.transaction;
|
||||
IDBDatabase.prototype.transaction = function (...args) {
|
||||
const transaction = Reflect.apply(originalTransaction, this, args);
|
||||
if (this.name === 'system-simulation-results') {
|
||||
const row = { start: performance.now(), mode: transaction.mode, stores: Array.from(transaction.objectStoreNames) };
|
||||
data.transactions.push(row);
|
||||
transaction.addEventListener('complete', () => { row.end = performance.now(); row.outcome = 'complete'; });
|
||||
transaction.addEventListener('abort', () => { row.end = performance.now(); row.outcome = 'abort'; });
|
||||
}
|
||||
return transaction;
|
||||
};
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (...args) {
|
||||
const value = Reflect.apply(originalSetItem, this, args);
|
||||
if (this === sessionStorage && args[0] === storageKey && data.activeRun) mark('indexedDbCommittedPointer');
|
||||
return value;
|
||||
};
|
||||
const NativeWorker = window.Worker;
|
||||
window.Worker = new Proxy(NativeWorker, {
|
||||
construct(target, args, newTarget) {
|
||||
const url = String(args[0]);
|
||||
const isCsv = /resultCsv/i.test(url);
|
||||
const start = performance.now();
|
||||
const worker = Reflect.construct(target, args, newTarget);
|
||||
if (!isCsv) return worker;
|
||||
const row = { url, constructStart: start, constructEnd: performance.now(), posts: [] };
|
||||
data.workers.push(row);
|
||||
worker.addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'complete') { row.completeReceived = performance.now(); row.blobBytes = message.blob?.size ?? null; }
|
||||
if (message?.type === 'error') { row.errorReceived = performance.now(); row.error = message.message; }
|
||||
});
|
||||
const originalPost = worker.postMessage;
|
||||
worker.postMessage = function (...postArgs) {
|
||||
const message = postArgs[0];
|
||||
// Read scalar metadata before transfer detaches the original buffer. Never
|
||||
// inspect/copy values, Blob contents, or inject code into the worker.
|
||||
const post = { type: message?.type, start: performance.now(), offset: message?.offset,
|
||||
bytes: message?.values?.byteLength ?? 0 };
|
||||
const result = Reflect.apply(originalPost, this, postArgs);
|
||||
post.end = performance.now();
|
||||
row.posts.push(post);
|
||||
return result;
|
||||
};
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
const originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||||
HTMLAnchorElement.prototype.click = function (...args) {
|
||||
if (this.download) data.downloads.push({ name: this.download, anchorClick: performance.now(), blobUrl: this.href.startsWith('blob:') });
|
||||
return Reflect.apply(originalAnchorClick, this, args);
|
||||
};
|
||||
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
|
||||
new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) if (data.activeRun) data.longTasks.push({ start: e.startTime, duration: e.duration, name: e.name });
|
||||
}).observe({ type: 'longtask', buffered: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Optional sampling diagnostics. These deliberately remain independent of production
|
||||
// code and existing stage marks. Analysis/source-map loading runs after all timings.
|
||||
async function calibrateCpuClock(session) {
|
||||
const samples = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const read = async () => (await session.send('Runtime.evaluate', {
|
||||
expression: '({now: performance.now(), timeOrigin: performance.timeOrigin})', returnByValue: true,
|
||||
})).result.value;
|
||||
const before = await read();
|
||||
const metrics = await session.send('Performance.getMetrics');
|
||||
const after = await read();
|
||||
const timestamp = metrics.metrics.find(metric => metric.name === 'Timestamp')?.value;
|
||||
if (timestamp === undefined || before.timeOrigin !== after.timeOrigin) continue;
|
||||
samples.push({ timeOrigin: before.timeOrigin, pageBeforeMs: before.now, pageAfterMs: after.now,
|
||||
cdpTimestampMs: timestamp * 1000, offsetMs: timestamp * 1000 - (before.now + after.now) / 2,
|
||||
uncertaintyMs: (after.now - before.now) / 2 });
|
||||
}
|
||||
if (!samples.length) throw new Error('Unable to calibrate CDP sampling against the page clock.');
|
||||
samples.sort((a, b) => a.uncertaintyMs - b.uncertaintyMs);
|
||||
return { chosen: samples[0], probes: samples };
|
||||
}
|
||||
|
||||
async function createCpuRecorder(context, page, intervalUs) {
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Performance.enable', { timeDomain: 'timeTicks' });
|
||||
await session.send('Profiler.enable');
|
||||
await session.send('Profiler.setSamplingInterval', { interval: intervalUs });
|
||||
let startCalibration;
|
||||
let running = false;
|
||||
return {
|
||||
async start() {
|
||||
if (running) throw new Error('CPU profiler already running.');
|
||||
startCalibration = await calibrateCpuClock(session);
|
||||
await session.send('Profiler.start');
|
||||
running = true;
|
||||
},
|
||||
async stop() {
|
||||
const { profile } = await session.send('Profiler.stop');
|
||||
running = false;
|
||||
const endCalibration = await calibrateCpuClock(session);
|
||||
return { profile, calibration: { start: startCalibration, end: endCalibration }, intervalUs };
|
||||
},
|
||||
async close() {
|
||||
if (running) await session.send('Profiler.stop').catch(() => {});
|
||||
await session.detach().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cpuFunctionCategories = {
|
||||
buildSystemXml: 'xml_generation', validateModel: 'model_validation', checkModel: 'model_validation',
|
||||
componentParameterValidationMessage: 'model_validation', projectExecutionContractIssues: 'model_validation',
|
||||
resolveSimulationConfig: 'model_validation', modelValidationSignature: 'model_validation',
|
||||
projectConnectionMetadata: 'model_contract_and_endpoints', buildCurrentProject: 'project_snapshot_copy',
|
||||
buildProjectPayload: 'project_snapshot_copy', cloneValue: 'project_snapshot_copy',
|
||||
publishSimulationResult: 'result_publication', normalizeSimulationProgressEvent: 'progress_normalization',
|
||||
storeResultSnapshot: 'persistence_pack_and_save', writeSnapshot: 'indexeddb_request_submission',
|
||||
restorePacked: 'persistence_unpack', loadStoredResultSnapshot: 'persistence_restore',
|
||||
openDatabase: 'indexeddb_open', deleteCache: 'persistence_cleanup',
|
||||
};
|
||||
|
||||
const cpuSourceRanges = new Map();
|
||||
function originalFunctionAt(content, line) {
|
||||
if (!content) return null;
|
||||
let ranges = cpuSourceRanges.get(content);
|
||||
if (!ranges) {
|
||||
const lines = content.split('\n');
|
||||
const declarations = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(\s*)(?:(?:export\s+)?(?:async\s+)?function\s+|const\s+)([A-Za-z_$][\w$]*)/);
|
||||
if (match) declarations.push({ name: match[2], indent: match[1].length, line: i + 1 });
|
||||
}
|
||||
ranges = [];
|
||||
for (const [index, declaration] of declarations.entries()) {
|
||||
if (!(declaration.name in cpuFunctionCategories)) continue;
|
||||
const next = declarations.slice(index + 1).find(peer => peer.indent <= declaration.indent);
|
||||
ranges.push({ name: declaration.name, startLine: declaration.line, endLineExclusive: next?.line ?? lines.length + 1 });
|
||||
}
|
||||
cpuSourceRanges.set(content, ranges);
|
||||
}
|
||||
// sourcesContent comes from the byte-verified bundle. Declaration ranges are
|
||||
// cached once per source, rather than rescanning App.tsx for every sampled frame.
|
||||
return ranges.findLast(range => line >= range.startLine && line < range.endLineExclusive) ?? null;
|
||||
}
|
||||
|
||||
async function loadVerifiedCpuMaps(sourceMapDirectory, servedAssets) {
|
||||
const consumers = new Map();
|
||||
const evidence = [];
|
||||
if (!sourceMapDirectory) return { consumers, evidence };
|
||||
const module = await import('../../frontend/node_modules/source-map-js/source-map.js');
|
||||
const { SourceMapConsumer } = module.default ?? module;
|
||||
const root = path.resolve(sourceMapDirectory);
|
||||
for (const asset of servedAssets.filter(asset => new URL(asset.url).pathname.endsWith('.js'))) {
|
||||
const pathname = decodeURIComponent(new URL(asset.url).pathname);
|
||||
const candidates = [path.resolve(root, `.${pathname}`), path.join(root, path.basename(pathname))];
|
||||
let generated = candidates[0];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.startsWith(`${root}${path.sep}`)) continue;
|
||||
if (await fs.stat(candidate).then(stat => stat.isFile()).catch(() => false)) { generated = candidate; break; }
|
||||
}
|
||||
if (!generated.startsWith(`${root}${path.sep}`)) continue;
|
||||
const mapPath = `${generated}.map`;
|
||||
try {
|
||||
const generatedBytes = await fs.readFile(generated);
|
||||
const generatedSha256 = sha(generatedBytes);
|
||||
if (generatedSha256 !== asset.sha256) {
|
||||
evidence.push({ url: asset.url, generated, status: 'rejected-generated-bytes-differ', generatedSha256, servedSha256: asset.sha256 });
|
||||
continue;
|
||||
}
|
||||
const mapBytes = await fs.readFile(mapPath);
|
||||
const consumer = new SourceMapConsumer(JSON.parse(mapBytes));
|
||||
consumers.set(asset.url, consumer);
|
||||
evidence.push({ url: asset.url, generated, mapPath, status: 'verified', generatedSha256, mapSha256: sha(mapBytes) });
|
||||
} catch (error) {
|
||||
evidence.push({ url: asset.url, generated, mapPath, status: 'unavailable', reason: String(error) });
|
||||
}
|
||||
}
|
||||
return { consumers, evidence };
|
||||
}
|
||||
|
||||
function cpuFrameInfo(frame, maps, cache) {
|
||||
const key = `${frame.scriptId}:${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.functionName}`;
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
let original = null;
|
||||
let sourceFunction = null;
|
||||
const consumer = maps.get(frame.url);
|
||||
if (consumer && frame.lineNumber >= 0 && frame.columnNumber >= 0) {
|
||||
const position = consumer.originalPositionFor({ line: frame.lineNumber + 1, column: frame.columnNumber });
|
||||
if (position.source && position.line) {
|
||||
original = position;
|
||||
sourceFunction = originalFunctionAt(consumer.sourceContentFor(position.source, true), position.line);
|
||||
}
|
||||
}
|
||||
const name = sourceFunction?.name ?? original?.name ?? frame.functionName;
|
||||
const source = original?.source ?? frame.url ?? '';
|
||||
let category = 'unclassified';
|
||||
let basis = 'unclassified';
|
||||
if (frame.functionName === '(idle)') { category = 'idle'; basis = 'v8-system-frame'; }
|
||||
else if (frame.functionName === '(garbage collector)') { category = 'garbage_collection'; basis = 'v8-system-frame'; }
|
||||
else if (['(program)', '(root)'].includes(frame.functionName)) { category = 'unclassified_program'; basis = 'v8-system-frame'; }
|
||||
else if (name in cpuFunctionCategories) { category = cpuFunctionCategories[name]; basis = sourceFunction ? 'verified-map-declaration-range' : original ? 'verified-map-name' : 'runtime-function-name'; }
|
||||
else if (/JSON\.parse/.test(name)) { category = 'json_parse'; basis = 'runtime-function-name'; }
|
||||
else if (/TextDecoder.*decode/.test(name)) { category = 'utf8_decode'; basis = 'runtime-function-name'; }
|
||||
else if (/XMLSerializer|serializeToString/.test(name)) { category = 'xml_native_serialization'; basis = 'runtime-function-name'; }
|
||||
else if (/structuredClone/.test(name)) { category = 'structured_clone'; basis = 'runtime-function-name'; }
|
||||
else if (/react-dom|react\/cjs|scheduler\/cjs/.test(source)) { category = 'react_runtime'; basis = original ? 'verified-map-source' : 'runtime-source-url'; }
|
||||
else if (/@xyflow/.test(source)) { category = 'diagram_reactflow'; basis = original ? 'verified-map-source' : 'runtime-source-url'; }
|
||||
else if (/ndjsonStream\./.test(source)) { category = 'ndjson_scan_join_dispatch'; basis = 'verified-map-source'; }
|
||||
else if (/resultPersistence\./.test(source)) { category = 'persistence_other'; basis = 'verified-map-source'; }
|
||||
else if (/resultCsvExport\./.test(source)) { category = 'csv_prepare_and_transfer'; basis = 'verified-map-source'; }
|
||||
else if (/chartData\.|resultEventSeries\./.test(source)) { category = 'chart_data_preparation'; basis = 'verified-map-source'; }
|
||||
else if (/SimulationResultsView\./.test(source)) { category = 'results_view_preparation'; basis = 'verified-map-source'; }
|
||||
else if (/componentSymbols|edgeRouting|ContactAwareEdge/.test(source)) { category = 'diagram_geometry'; basis = 'verified-map-source'; }
|
||||
else if (/App\.tsx/.test(source)) { category = 'application_other'; basis = 'verified-map-source'; }
|
||||
else if (/__playwright|utilityScript|evaluate@/.test(source + name)) { category = 'measurement_or_automation'; basis = 'runtime-frame'; }
|
||||
const result = { key, functionName: frame.functionName, hasScript: Boolean(frame.scriptId && frame.scriptId !== '0'), generated: { url: frame.url, line: frame.lineNumber + 1, column: frame.columnNumber },
|
||||
original, sourceFunction, category, classificationBasis: basis };
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function cpuWindows(trace, kind) {
|
||||
const m = trace.marks;
|
||||
const endCommit = m.indexedDbCommittedPointer ?? m.indexedDbPointerObserved;
|
||||
const windows = kind === 'restore' ? [
|
||||
['restore_navigation_to_dom', 0, m.restoredResultsDom],
|
||||
['restore_navigation_to_paint_opportunity', 0, m.restoredResultsPaintOpportunity],
|
||||
] : [
|
||||
['import', m.importChange, m.importReadyPaintOpportunity],
|
||||
['pre_submit', m.runClick, m.fetchStart],
|
||||
['response_before_result_parse', m.headers, m.resultParseStart],
|
||||
['result_json_parse', m.resultParseStart, m.resultParseEnd],
|
||||
['result_publish_to_dom', m.resultParseEnd, m.resultReadyDom],
|
||||
['result_ready_to_saved_pointer', m.resultReadyDom, endCommit],
|
||||
['result_parse_through_saved_pointer', m.resultParseStart, endCommit],
|
||||
['result_parsed_to_saved_pointer', m.resultParseEnd, endCommit],
|
||||
['result_tab_preparation', m.resultTabClick, m.resultsPaintOpportunity],
|
||||
['temperature_chart_preparation', m.temperatureClick, m.curvePaintOpportunity],
|
||||
['csv_export_through_download_saved', m.csvClick, m.csvDownloadSaved],
|
||||
['result_export_through_download_saved', m.resultFileClick, m.resultFileDownloadSaved],
|
||||
['whole_run_click_through_download_saved', m.runClick, m.resultFileDownloadSaved],
|
||||
];
|
||||
return windows.filter(([, start, end]) => Number.isFinite(start) && Number.isFinite(end) && end >= start)
|
||||
.map(([name, startMs, endMs]) => ({ name, startMs, endMs, wallMs: endMs - startMs }));
|
||||
}
|
||||
|
||||
function cpuExecutionState(stack) {
|
||||
const leaf = stack[0];
|
||||
if (!leaf) return 'unknown_runtime';
|
||||
if (['idle', 'garbage_collection', 'unclassified_program'].includes(leaf.category)) return leaf.category;
|
||||
if (leaf.hasScript) return 'active_js';
|
||||
// Native call frames (for example IDBObjectStore.put or Blob) may be sampled
|
||||
// under a real JS caller. A bare runtime/native frame has no such evidence.
|
||||
return stack.some(frame => frame.hasScript) ? 'active_native_call' : 'unknown_runtime';
|
||||
}
|
||||
|
||||
function summarizeCpu(record, maps) {
|
||||
const { profile, trace, intervalUs, calibration, kind } = record;
|
||||
const anchors = [calibration.start.chosen, calibration.end.chosen].filter(anchor => anchor.timeOrigin === trace.timeOrigin);
|
||||
if (!anchors.length) throw new Error('CPU profile calibration does not match its document timeOrigin.');
|
||||
const anchor = anchors.reduce((best, candidate) => candidate.uncertaintyMs < best.uncertaintyMs ? candidate : best);
|
||||
const offsetSpreadMs = Math.max(...anchors.map(a => a.offsetMs)) - Math.min(...anchors.map(a => a.offsetMs));
|
||||
const frameCache = new Map();
|
||||
const nodes = new Map(profile.nodes.map(node => [node.id, { ...node, info: cpuFrameInfo(node.callFrame, maps, frameCache) }]));
|
||||
const parent = new Map();
|
||||
for (const node of profile.nodes) for (const child of node.children ?? []) parent.set(child, node.id);
|
||||
const stacks = new Map();
|
||||
for (const node of profile.nodes) {
|
||||
const stack = []; let id = node.id;
|
||||
while (nodes.has(id) && stack.length < 256) { stack.push(nodes.get(id).info); id = parent.get(id); }
|
||||
stacks.set(node.id, stack);
|
||||
}
|
||||
let clockMs = profile.startTime / 1000 - anchor.offsetMs;
|
||||
const samples = (profile.samples ?? []).map((id, index) => {
|
||||
const deltaMs = (profile.timeDeltas?.[index] ?? intervalUs) / 1000;
|
||||
const startMs = clockMs; clockMs += deltaMs;
|
||||
// Sampling gaps can include OS descheduling; never credit a long unsampled
|
||||
// gap wholesale to the currently sampled JavaScript function.
|
||||
return { id, startMs, endMs: clockMs, representedMs: Math.min(deltaMs, 2 * intervalUs / 1000) };
|
||||
});
|
||||
const windows = cpuWindows(trace, kind).map(window => {
|
||||
const categories = new Map(); const functions = new Map();
|
||||
const executionSampleMs = { active_js: 0, active_native_call: 0, idle: 0,
|
||||
garbage_collection: 0, unclassified_program: 0, unknown_runtime: 0 };
|
||||
let sampleCount = 0; let representedMs = 0; let samplingGapMs = 0;
|
||||
for (const sample of samples) {
|
||||
const overlap = Math.max(0, Math.min(window.endMs, sample.endMs) - Math.max(window.startMs, sample.startMs));
|
||||
if (!overlap) continue;
|
||||
const interval = sample.endMs - sample.startMs;
|
||||
const weight = interval > 0 ? overlap * sample.representedMs / interval : 0;
|
||||
sampleCount++; representedMs += weight; samplingGapMs += Math.max(0, overlap - weight);
|
||||
const stack = stacks.get(sample.id) ?? [];
|
||||
const state = cpuExecutionState(stack);
|
||||
executionSampleMs[state] += weight;
|
||||
const active = state === 'active_js' || state === 'active_native_call';
|
||||
// Never walk up to V8 (root)/(program) to classify unknown JavaScript.
|
||||
// GC/idle/program samples also never accrue to an application caller.
|
||||
const activeStack = stack.filter(frame => frame.classificationBasis !== 'v8-system-frame');
|
||||
const owner = activeStack.find(frame => !['unclassified', 'application_other'].includes(frame.category)) ?? activeStack[0];
|
||||
const category = active ? owner?.category ?? 'unclassified' : state;
|
||||
categories.set(category, (categories.get(category) ?? 0) + weight);
|
||||
if (!active) continue;
|
||||
const seen = new Set();
|
||||
for (const [index, frame] of activeStack.entries()) {
|
||||
if (seen.has(frame.key)) continue;
|
||||
seen.add(frame.key);
|
||||
const entry = functions.get(frame.key) ?? { ...frame, selfActiveEstimatedMs: 0, inclusiveActiveEstimatedMs: 0 };
|
||||
if (index === 0) entry.selfActiveEstimatedMs += weight;
|
||||
entry.inclusiveActiveEstimatedMs += weight;
|
||||
functions.set(frame.key, entry);
|
||||
}
|
||||
}
|
||||
return { ...window, sampleCount, representedSampleMs: representedMs, executionSampleMs,
|
||||
activeJsAndNativeCallSampleMs: executionSampleMs.active_js + executionSampleMs.active_native_call,
|
||||
samplingGapMs, unattributedWallMs: Math.max(0, window.wallMs - representedMs - samplingGapMs),
|
||||
exclusiveOwnerCategories: Object.fromEntries([...categories].sort((a, b) => b[1] - a[1])),
|
||||
functions: [...functions.values()].sort((a, b) => b.inclusiveActiveEstimatedMs - a.inclusiveActiveEstimatedMs) };
|
||||
});
|
||||
return { schemaVersion: 2, kind, intervalUs, timeOrigin: trace.timeOrigin, profileStartUs: profile.startTime, profileEndUs: profile.endTime,
|
||||
profilePageStartMs: profile.startTime / 1000 - anchor.offsetMs, profilePageEndMs: profile.endTime / 1000 - anchor.offsetMs,
|
||||
clockCalibration: { anchor, offsetSpreadMs, probes: calibration }, windows,
|
||||
limitations: [
|
||||
'Sampling estimates renderer-main-thread execution attribution; it is not a function stopwatch or an OS thread CPU clock.',
|
||||
'representedSampleMs includes idle, GC and unknown runtime samples. It is NOT an active CPU total.',
|
||||
'activeJsAndNativeCallSampleMs includes sampled JavaScript and native calls with a JavaScript ancestor; bare native/runtime frames remain unknown.',
|
||||
'V8 (program)/(root) samples are unclassified_program, not evidence of JavaScript or native CPU activity. Do not attribute this interval to receiving, parsing, painting or another residual category.',
|
||||
'Each sample represents at most two configured sampling intervals; larger gaps are unassigned scheduling/sampling gaps.',
|
||||
'Function selfActiveEstimatedMs/inclusiveActiveEstimatedMs exclude idle, GC, program and unknown-runtime samples even if they have application ancestors.',
|
||||
'Active inclusive estimates include active children and cannot be added together; stage windows also overlap and cannot be added.',
|
||||
'Synchronous JSON.parse/decoder durations in trace.json remain direct wrapper measurements; do not add sampling estimates to them.',
|
||||
'Renderer main-thread profiling excludes CSV Worker CPU, IndexedDB background/disk work, compositor/GPU work and backend CPU.',
|
||||
'React/source categories describe JavaScript preparation, not actual paint completion.',
|
||||
'Without byte-verified source maps, minified function ownership stays unknown; original declaration ranges are a source attribution aid, not exact instruction boundaries.',
|
||||
'Clock alignment uses CDP timeTicks and bracketing page-clock reads; uncertainty and before/after offset spread are reported.',
|
||||
'Deep profiling perturbs execution; compare endpoint timings using separate runs without --deep.',
|
||||
] };
|
||||
}
|
||||
|
||||
function cpuSummaryMarkdown(summary, filename) {
|
||||
const lines = [`CPU sampling diagnostic: ${filename}`, '', `Schema ${summary.schemaVersion}; interval ${summary.intervalUs} us. Main-thread samples only.`,
|
||||
`Clock uncertainty: ${summary.clockCalibration.anchor.uncertaintyMs.toFixed(3)} ms; offset spread: ${summary.clockCalibration.offsetSpreadMs.toFixed(3)} ms.`, '',
|
||||
'Active = JavaScript plus native calls sampled under JavaScript. Program/unknown and idle are not counted as active CPU.', '',
|
||||
'| Window (overlaps allowed) | Wall ms | Active sample ms | Idle ms | GC ms | Program/unknown ms | Sampling gap ms | Leading active owner categories |',
|
||||
'|---|---:|---:|---:|---:|---:|---:|---|'];
|
||||
for (const window of summary.windows) {
|
||||
const states = window.executionSampleMs;
|
||||
const top = Object.entries(window.exclusiveOwnerCategories).filter(([name]) => !['idle', 'garbage_collection', 'unclassified_program', 'unknown_runtime'].includes(name))
|
||||
.slice(0, 5).map(([name, ms]) => `${name}: ${ms.toFixed(2)}`).join('; ');
|
||||
lines.push(`| ${window.name} | ${window.wallMs.toFixed(2)} | ${window.activeJsAndNativeCallSampleMs.toFixed(2)} | ${states.idle.toFixed(2)} | ${states.garbage_collection.toFixed(2)} | ${(states.unclassified_program + states.unknown_runtime).toFixed(2)} | ${window.samplingGapMs.toFixed(2)} | ${top} |`);
|
||||
}
|
||||
for (const window of summary.windows) {
|
||||
lines.push('', `Top active sampled functions: ${window.name}`, '');
|
||||
lines.push(...window.functions.filter(frame => frame.selfActiveEstimatedMs > 0).sort((a, b) => b.selfActiveEstimatedMs - a.selfActiveEstimatedMs).slice(0, 8).map(frame => {
|
||||
const location = frame.original ? `${frame.original.source}:${frame.original.line}:${frame.original.column}`
|
||||
: `${frame.generated.url || '(native/injected)'}:${frame.generated.line}:${frame.generated.column}`;
|
||||
return `- ${frame.sourceFunction?.name ?? frame.original?.name ?? frame.functionName ?? '(anonymous)'} — active self ${frame.selfActiveEstimatedMs.toFixed(2)} ms, active inclusive ${frame.inclusiveActiveEstimatedMs.toFixed(2)} ms; ${location}; ${frame.classificationBasis}`;
|
||||
}));
|
||||
}
|
||||
lines.push('', 'Limitations:', '', ...summary.limitations.map(text => `- ${text}`), '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Small synthetic sampling contract: nested GC is separate, a bare program frame
|
||||
// remains unknown, minified JS cannot inherit (root), and long gaps stay unassigned.
|
||||
function checkCpuSummaryContract() {
|
||||
const frame = (functionName, scriptId = '0') => ({ functionName, scriptId, url: scriptId === '0' ? '' : 'fixture.js', lineNumber: scriptId === '0' ? -1 : 0, columnNumber: 0 });
|
||||
const profile = { startTime: 0, endTime: 14000, nodes: [
|
||||
{ id: 1, callFrame: frame('(root)'), children: [2, 3, 4, 8] },
|
||||
{ id: 2, callFrame: frame('(idle)') }, { id: 3, callFrame: frame('(program)') },
|
||||
{ id: 4, callFrame: frame('buildSystemXml', '1'), children: [5, 6] },
|
||||
{ id: 5, callFrame: frame('(garbage collector)') },
|
||||
{ id: 6, callFrame: frame('anonymousMinified', '1'), children: [7] },
|
||||
{ id: 7, callFrame: frame('put') }, { id: 8, callFrame: frame('unresolvedNative') },
|
||||
], samples: [2, 3, 4, 5, 6, 7, 8], timeDeltas: [1000, 1000, 1000, 1000, 1000, 1000, 8000] };
|
||||
const chosen = { timeOrigin: 0, offsetMs: 0, uncertaintyMs: 0 };
|
||||
const summary = summarizeCpu({ profile, kind: 'interaction', trace: { timeOrigin: 0, marks: { runClick: 0, fetchStart: 14 } },
|
||||
calibration: { start: { chosen }, end: { chosen } }, intervalUs: 1000 }, new Map());
|
||||
const window = summary.windows[0];
|
||||
assert.deepEqual(window.executionSampleMs, { active_js: 2, active_native_call: 1, idle: 1, garbage_collection: 1, unclassified_program: 1, unknown_runtime: 2 });
|
||||
assert.equal(window.activeJsAndNativeCallSampleMs, 3);
|
||||
assert.equal(window.samplingGapMs, 6);
|
||||
assert.equal(window.exclusiveOwnerCategories.xml_generation, 3);
|
||||
assert.equal(window.functions.find(item => item.functionName === 'buildSystemXml').inclusiveActiveEstimatedMs, 3);
|
||||
assert.equal(window.functions.some(item => item.classificationBasis === 'v8-system-frame'), false);
|
||||
// A JS leaf directly under root must be active with unknown ownership.
|
||||
const isolated = structuredClone(profile);
|
||||
isolated.nodes[0].children.push(9);
|
||||
isolated.nodes.push({ id: 9, callFrame: frame('minifiedUnknown', '1') });
|
||||
isolated.samples = [9]; isolated.timeDeltas = [1000]; isolated.endTime = 1000;
|
||||
const unknown = summarizeCpu({ profile: isolated, kind: 'interaction', trace: { timeOrigin: 0, marks: { runClick: 0, fetchStart: 1 } },
|
||||
calibration: { start: { chosen }, end: { chosen } }, intervalUs: 1000 }, new Map()).windows[0];
|
||||
assert.equal(unknown.exclusiveOwnerCategories.unclassified, 1);
|
||||
assert.equal(unknown.executionSampleMs.unclassified_program, 0);
|
||||
}
|
||||
|
||||
function cpuMeasuredMedians(records) {
|
||||
const median = values => { const sorted = values.sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; };
|
||||
const groups = new Map();
|
||||
for (const record of records.filter(record => /(?:^|[/\\])(?:control|profiled)-run-\d+(?:[/\\]|$)/.test(record.profile))) {
|
||||
const mode = record.profile.match(/(?:control|profiled)-run-\d+/)[0].split('-')[0];
|
||||
for (const window of record.summary.windows) {
|
||||
const key = `${mode}:${record.kind}:${window.name}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(window);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries([...groups].map(([key, windows]) => {
|
||||
const categories = new Set(windows.flatMap(window => Object.keys(window.exclusiveOwnerCategories)));
|
||||
return [key, { samples: windows.length,
|
||||
...Object.fromEntries(['wallMs', 'activeJsAndNativeCallSampleMs', 'representedSampleMs', 'samplingGapMs', 'unattributedWallMs']
|
||||
.map(field => [field, median(windows.map(window => window[field]))])),
|
||||
executionSampleMs: Object.fromEntries(Object.keys(windows[0].executionSampleMs).map(state => [state, median(windows.map(window => window.executionSampleMs[state]))])),
|
||||
exclusiveOwnerCategories: Object.fromEntries([...categories].map(category => [category, median(windows.map(window => window.exclusiveOwnerCategories[category] ?? 0))])
|
||||
.sort((a, b) => b[1] - a[1])),
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
async function summarizeExistingCpu(directory, sourceMapDirectory) {
|
||||
const summaryFile = path.join(directory, 'summary.json');
|
||||
const timingBytes = await fs.readFile(summaryFile);
|
||||
const timing = JSON.parse(timingBytes);
|
||||
assert.ok(timing.cpuDiagnostics?.profiles?.length, 'No captured CPU profiles in summary.json.');
|
||||
const sourceDirectory = sourceMapDirectory ?? timing.cpuDiagnostics.sourceMaps.find(item => item.status === 'verified')?.generated;
|
||||
const { consumers, evidence: sourceMaps } = await loadVerifiedCpuMaps(sourceMapDirectory ?? (sourceDirectory && path.dirname(sourceDirectory)), timing.servedAssets);
|
||||
const records = [];
|
||||
const index = { schemaVersion: 2, browserLaunched: false, sourceTimingSummary: summaryFile, sourceTimingSha256: sha(timingBytes),
|
||||
scriptSha256: sha(await fs.readFile(new URL(import.meta.url))), sourceMaps, profiles: [] };
|
||||
for (const entry of timing.cpuDiagnostics.profiles) {
|
||||
// Prefer paths relative to the supplied directory so captured runs can be moved.
|
||||
const profileFile = path.join(directory, path.basename(path.dirname(entry.profile)), path.basename(entry.profile));
|
||||
const oldSummaryFile = path.join(path.dirname(profileFile), path.basename(entry.summary));
|
||||
const traceFile = path.join(path.dirname(profileFile), entry.kind === 'restore' ? 'restore-trace.json' : 'trace.json');
|
||||
const [profileBytes, previousBytes, traceBytes] = await Promise.all([fs.readFile(profileFile), fs.readFile(oldSummaryFile), fs.readFile(traceFile)]);
|
||||
const previous = JSON.parse(previousBytes);
|
||||
const record = { profile: JSON.parse(profileBytes), trace: JSON.parse(traceBytes), kind: entry.kind,
|
||||
intervalUs: previous.intervalUs, calibration: previous.clockCalibration.probes };
|
||||
const summary = summarizeCpu(record, consumers);
|
||||
const output = profileFile.replace(/\.cpuprofile$/, '-cpu-summary-v2.json');
|
||||
await fs.writeFile(output, JSON.stringify(summary, null, 2));
|
||||
await fs.writeFile(output.replace(/\.json$/, '.md'), cpuSummaryMarkdown(summary, profileFile));
|
||||
index.profiles.push({ profile: profileFile, profileSha256: sha(profileBytes), trace: traceFile, traceSha256: sha(traceBytes),
|
||||
sourceSummary: oldSummaryFile, sourceSummarySha256: sha(previousBytes), summary: output, kind: entry.kind });
|
||||
records.push({ profile: profileFile, kind: entry.kind, summary });
|
||||
// Offline analysis must never change recorded timing, traces or raw profiles.
|
||||
assert.equal(sha(await fs.readFile(profileFile)), sha(profileBytes));
|
||||
assert.equal(sha(await fs.readFile(traceFile)), sha(traceBytes));
|
||||
}
|
||||
index.measuredMedians = cpuMeasuredMedians(records);
|
||||
index.medianDefinition = 'Per-field medians over measured runs only; warmups excluded. Median columns and overlapping windows are not additive. Active includes JS plus native calls under JS, excluding idle/GC/program/unknown runtime.';
|
||||
assert.equal(sha(await fs.readFile(summaryFile)), sha(timingBytes));
|
||||
const indexFile = path.join(directory, 'cpu-resummary-v2.json');
|
||||
await fs.writeFile(indexFile, JSON.stringify(index, null, 2));
|
||||
console.log(JSON.stringify({ indexFile, profiles: records.length, originalTimingAndProfilesUnchanged: true, browserLaunched: false }));
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
checkCpuSummaryContract();
|
||||
if (options.summarizeCpuOnly) {
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(options.summarizeCpuOnly, 'summary.json')));
|
||||
assert.ok(manifest.cpuDiagnostics?.profiles?.length);
|
||||
console.log(JSON.stringify({ directory: options.summarizeCpuOnly, profiles: manifest.cpuDiagnostics.profiles.length,
|
||||
cpuClassificationContractPassed: true, browserLaunched: false }));
|
||||
} else {
|
||||
console.log(JSON.stringify({ input: options.input, inputSha256: sha(inputText), nodes: project.nodes.length,
|
||||
edges: project.edges.length, curveNodeId, mode: options.mode, deep: Boolean(options.deep), cpuIntervalUs: Number(options.cpuIntervalUs),
|
||||
sourceMapDir: options.sourceMapDir ?? null, warmupsPerMode: 1, measuredRunsPerMode: Number(options.runs),
|
||||
cpuClassificationContractPassed: true, browserLaunched: false }));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (options.summarizeCpuOnly) {
|
||||
checkCpuSummaryContract();
|
||||
await summarizeExistingCpu(options.summarizeCpuOnly, options.sourceMapDir);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const definitions = {
|
||||
clock: 'All timestamps are performance.now() in the stated document timeOrigin; reload uses a separate time axis.',
|
||||
clickToFetch: 'Includes model checking, snapshot construction, XML generation and submission setup; not isolated XML CPU time.',
|
||||
headersAndReads: 'fetch resolution and consumer read delivery. Outstanding-read intervals include backend production, transport and browser scheduling; not pure network time.',
|
||||
unobservedCpu: 'NDJSON fragment scanning/join/trim and React handler CPU are not isolated. Their residual intervals can also include scheduling and cannot be attributed wholesale to parsing, transport or drawing.',
|
||||
jsonParse: 'Only the original synchronous JSON.parse call, called once per application parse. No duplicate body read, decode, scan or parse.',
|
||||
resultReady: 'First DOM observation of successful completion plus an enabled run button after busy state. React state/handler boundaries are not directly instrumented.',
|
||||
indexedDb: 'Profiled: session pointer publication immediately after all save transactions commit. Control: pointer polling, up to 16 ms plus scheduling delay. Transaction windows also include asynchronous waiting and may include old-cache cleanup.',
|
||||
render: 'First visible DOM, then two requestAnimationFrame callbacks (paint opportunity, not GPU completion); stable means scoped DOM quiet for 120 ms followed by two frames.',
|
||||
export: 'User click to blob anchor invocation (profiled) and Playwright download completion observed back on the page clock. Completion includes automation notification and filesystem saveAs overhead.',
|
||||
worker: 'Profiled only: native Worker construction, original postMessage calls with unchanged transfer lists, and complete-message receipt on the document clock. No worker injection, payload copy or Blob read. Finish-post to receipt includes worker scheduling/encoding/Blob creation/message delivery, not isolated worker CPU. Main-thread preparation between posts overlaps worker activity and includes deliberate yields.',
|
||||
control: 'Same user workflow and minimal click/DOM observations, without fetch/reader/JSON/decoder/IDB/Storage/Worker/anchor wrappers or long-task observer.',
|
||||
overlap: 'Intervals overlap. Do not add read waits, parse, main-thread tasks, rendering, persistence, backend native time or residual differences as exclusive costs.',
|
||||
import: 'Every run reimports the fixed input before runClick. Timing is native file-input change through import-success DOM and two frames. Public project export then verifies node/edge counts, all parameters and endpoints outside simulation timing.',
|
||||
warmup: 'One complete warmup per mode excluded from measured summaries. Modes run sequentially; ordering/cache/thermal effects remain possible.',
|
||||
};
|
||||
const cpuRecords = [];
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const evidence = { input: path.resolve(options.input), inputSha256: sha(inputText), baseURL: options.url,
|
||||
browser: browser.version(), node: process.version, deep: Boolean(options.deep), cpuIntervalUs: options.deep ? Number(options.cpuIntervalUs) : null, initialNavigation: {}, scriptSha256: sha(await fs.readFile(new URL(import.meta.url))), definitions, rows, errors, servedAssets: [] };
|
||||
const writeSummary = () => fs.writeFile(path.join(options.output, 'summary.json'), JSON.stringify(evidence, null, 2));
|
||||
const waitMark = async (page, name) => {
|
||||
await page.waitForFunction(key => window.__stageProfile?.marks[key] !== undefined || window.__stageProfile?.marks.runFailure !== undefined, name, { timeout: 180000 });
|
||||
const failure = await page.evaluate(() => window.__stageProfile.runFailure);
|
||||
if (failure) throw new Error(`Page simulation failed: ${failure}`);
|
||||
};
|
||||
const pageMark = (page, name) => page.evaluate(key => window.__stageProfile.mark(key), name);
|
||||
const arm = (page, name, selector) => page.evaluate(({ name, selector }) => window.__stageProfile.armClick(name, selector), { name, selector });
|
||||
const watch = (page, name, selector) => page.evaluate(({ name, selector }) => window.__stageProfile.watchDom(name, selector), { name, selector });
|
||||
const clickTab = async (page, name) => { await page.getByRole('tab', { name }).click(); };
|
||||
const resultDigest = result => sha(JSON.stringify(result));
|
||||
const delta = (m, a, b) => m[a] === undefined || m[b] === undefined ? null : m[b] - m[a];
|
||||
function metrics(trace) {
|
||||
const m = trace.marks;
|
||||
const csvAnchor = trace.downloads.find(d => d.name.endsWith('.csv'))?.anchorClick;
|
||||
const resultAnchor = trace.downloads.find(d => d.name.endsWith('.simresult'))?.anchorClick;
|
||||
const csvRequest = trace.requests.find(r => r.kind === 'csv');
|
||||
const worker = trace.workers?.[0];
|
||||
const workerStart = worker?.posts.find(p => p.type === 'start');
|
||||
const workerFinish = worker?.posts.find(p => p.type === 'finish');
|
||||
return {
|
||||
importToDomMs: delta(m, 'importChange', 'importReadyDom'),
|
||||
importToPaintOpportunityMs: delta(m, 'importChange', 'importReadyPaintOpportunity'),
|
||||
clickToFetchMs: delta(m, 'runClick', 'fetchStart'), fetchToHeadersMs: delta(m, 'fetchStart', 'headers'),
|
||||
headersToEofMs: delta(m, 'headers', 'streamEof'), clickToReadyDomMs: delta(m, 'runClick', 'resultReadyDom'),
|
||||
clickToReadyPaintOpportunityMs: delta(m, 'runClick', 'resultReadyPaintOpportunity'),
|
||||
resultParseMs: delta(m, 'resultParseStart', 'resultParseEnd'),
|
||||
resultParseEndToReadyDomMs: delta(m, 'resultParseEnd', 'resultReadyDom'),
|
||||
clickToIndexedDbCommitMs: delta(m, 'runClick', 'indexedDbCommittedPointer'),
|
||||
clickToIndexedDbObservedMs: delta(m, 'runClick', 'indexedDbPointerObserved'),
|
||||
resultTabToDomMs: delta(m, 'resultTabClick', 'resultsDom'),
|
||||
resultTabToPaintOpportunityMs: delta(m, 'resultTabClick', 'resultsPaintOpportunity'),
|
||||
resultTabToDomStableMs: delta(m, 'resultTabClick', 'resultsStable'),
|
||||
curveSelectToDomMs: delta(m, 'temperatureClick', 'curveDom'),
|
||||
curveSelectToPaintOpportunityMs: delta(m, 'temperatureClick', 'curvePaintOpportunity'),
|
||||
curveSelectToDomStableMs: delta(m, 'temperatureClick', 'curveStable'),
|
||||
csvClickToWorkerConstructMs: worker && m.csvClick !== undefined ? worker.constructStart - m.csvClick : null,
|
||||
csvWorkerConstructMs: worker ? worker.constructEnd - worker.constructStart : null,
|
||||
csvWorkerStartToFinishPostMs: workerStart && workerFinish ? workerFinish.end - workerStart.start : null,
|
||||
csvWorkerFinishPostToCompleteReceivedMs: workerFinish && worker.completeReceived !== undefined ? worker.completeReceived - workerFinish.end : null,
|
||||
csvWorkerStartToCompleteReceivedMs: workerStart && worker.completeReceived !== undefined ? worker.completeReceived - workerStart.start : null,
|
||||
csvWorkerPostSyncTotalMs: worker ? worker.posts.reduce((total, post) => total + post.end - post.start, 0) : null,
|
||||
csvWorkerTransferredBytes: worker ? worker.posts.reduce((total, post) => total + post.bytes, 0) : null,
|
||||
csvClickToFetchMs: csvRequest && m.csvClick !== undefined ? csvRequest.fetchStart - m.csvClick : null,
|
||||
csvFetchToHeadersMs: csvRequest?.headers === undefined ? null : csvRequest.headers - csvRequest.fetchStart,
|
||||
csvClickToBlobAnchorMs: csvAnchor === undefined ? null : csvAnchor - m.csvClick,
|
||||
resultClickToBlobAnchorMs: resultAnchor === undefined ? null : resultAnchor - m.resultFileClick,
|
||||
csvClickToDownloadSavedMs: delta(m, 'csvClick', 'csvDownloadSaved'),
|
||||
resultClickToDownloadSavedMs: delta(m, 'resultFileClick', 'resultFileDownloadSaved'),
|
||||
streamReadCount: trace.profiled ? trace.reads.filter(r => r.kind === 'simulation').length : null,
|
||||
streamBytes: trace.profiled ? trace.reads.reduce((n, r) => n + (r.kind === 'simulation' ? r.bytes : 0), 0) : null,
|
||||
streamOutstandingReadMs: trace.profiled ? trace.reads.reduce((n, r) => n + (r.kind === 'simulation' ? r.end - r.start : 0), 0) : null,
|
||||
synchronousStreamJsonParseMs: trace.profiled ? trace.parses.reduce((n, r) => n + r.end - r.start, 0) : null,
|
||||
synchronousStreamDecodeMs: trace.profiled ? trace.decodes.reduce((n, r) => n + r.end - r.start, 0) : null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
for (const mode of options.mode === 'both' ? ['control', 'profiled'] : [options.mode]) {
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
|
||||
await context.addInitScript(installStageObserver, { profiled: mode === 'profiled' });
|
||||
const page = await context.newPage();
|
||||
const cpuRecorder = options.deep ? await createCpuRecorder(context, page, Number(options.cpuIntervalUs)) : null;
|
||||
page.setDefaultTimeout(30000);
|
||||
const simulationRequests = [];
|
||||
const workerUrls = new Set();
|
||||
page.on('worker', worker => workerUrls.add(worker.url()));
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/api/system-xml/simulate-stream')) {
|
||||
simulationRequests.push({ url: request.url(), simulationId: request.headers()['x-simulation-id'] ?? null });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', error => errors.push({ mode, error: String(error) }));
|
||||
page.on('dialog', dialog => { errors.push({ mode, dialog: dialog.message() }); void dialog.dismiss(); });
|
||||
try {
|
||||
await page.goto(options.url);
|
||||
await page.locator('input[type="file"][accept*=".json"]').waitFor({ state: 'attached' });
|
||||
evidence.initialNavigation[mode] = await page.evaluate(() => ({ timeOrigin: performance.timeOrigin,
|
||||
appControlObservedAt: performance.now(), navigation: performance.getEntriesByType('navigation').map(e => e.toJSON()) }));
|
||||
const assetUrls = await page.evaluate(() => [...document.querySelectorAll('script[src],link[rel="stylesheet"][href]')]
|
||||
.map(e => e.src || e.href));
|
||||
if (assetUrls.some(url => /@vite\/client|\/src\//.test(url))) throw new Error('Expected a production build, found Vite development assets.');
|
||||
for (const url of assetUrls) {
|
||||
const response = await context.request.get(url);
|
||||
assert.ok(response.ok(), `Asset HTTP ${response.status()}: ${url}`);
|
||||
const bytes = await response.body();
|
||||
const previous = evidence.servedAssets.find(asset => asset.url === url);
|
||||
if (previous) assert.equal(previous.sha256, sha(bytes), 'Production asset changed between modes.');
|
||||
else evidence.servedAssets.push({ url, sha256: sha(bytes), bytes: bytes.length });
|
||||
}
|
||||
assert.ok(evidence.servedAssets.length, 'No production assets found.');
|
||||
evidence.buildAssetSetSha256 = sha(JSON.stringify(evidence.servedAssets
|
||||
.map(({ url, ...asset }) => ({ path: new URL(url).pathname, ...asset }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path))));
|
||||
for (let run = 0; run <= Number(options.runs); run++) {
|
||||
const prefix = `${mode}-${run === 0 ? 'warmup' : `run-${run}`}`;
|
||||
const runDir = path.join(options.output, prefix);
|
||||
await fs.mkdir(runDir, { recursive: true });
|
||||
await clickTab(page, '建模');
|
||||
const expand = page.getByRole('button', { name: '展开仿真控制台', exact: true });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
const requestOffset = simulationRequests.length;
|
||||
if (cpuRecorder) await cpuRecorder.start();
|
||||
await page.evaluate(name => { window.__stageProfile.reset(); window.__stageProfile.watchImport(name); },
|
||||
path.basename(options.input, path.extname(options.input)));
|
||||
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(path.resolve(options.input));
|
||||
await waitMark(page, 'importReadyPaintOpportunity');
|
||||
// Verify the public project export outside simulation timing; importing after each
|
||||
// reload avoids assuming that result recovery also restores the modeling workspace.
|
||||
const projectDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: '导出工程 JSON', exact: true }).click();
|
||||
await (await projectDownload).saveAs(path.join(runDir, 'imported-project.json'));
|
||||
const imported = JSON.parse(await fs.readFile(path.join(runDir, 'imported-project.json'), 'utf8'));
|
||||
assert.equal(imported.nodes.length, project.nodes.length);
|
||||
assert.equal(imported.edges.length, project.edges.length);
|
||||
assert.equal(imported.name, path.basename(options.input, path.extname(options.input)));
|
||||
assert.deepEqual(imported.simulation, project.simulation);
|
||||
for (const node of project.nodes) assert.deepEqual(imported.nodes.find(n => n.id === node.id)?.data.parameters, node.data.parameters);
|
||||
for (const edge of project.edges) {
|
||||
const actual = imported.edges.find(e => e.id === edge.id);
|
||||
for (const key of ['source', 'target', 'sourceHandle', 'targetHandle']) assert.equal(actual?.[key], edge[key]);
|
||||
}
|
||||
await page.evaluate(() => window.__stageProfile.watchRunReady());
|
||||
await arm(page, 'runClick', 'button[aria-label="运行仿真"]');
|
||||
await page.getByRole('button', { name: '运行仿真', exact: true }).click();
|
||||
await waitMark(page, 'resultReadyPaintOpportunity');
|
||||
// Open results immediately; persistence is allowed to overlap exactly as in real use.
|
||||
await arm(page, 'resultTabClick', '[role="tab"]');
|
||||
await watch(page, 'results', '.results-shell .results-system-panel');
|
||||
await clickTab(page, /^结果/);
|
||||
await waitMark(page, 'resultsStable');
|
||||
await page.getByRole('button', { name: '适应系统图窗口', exact: true }).click();
|
||||
await page.locator(`.results-system-panel .react-flow__node[data-id=${JSON.stringify(curveNodeId)}]`).click();
|
||||
// Remove persisted chart windows outside the curve-selection timing interval.
|
||||
const close = page.locator('.result-chart-window .result-chart-window-actions button.close');
|
||||
while (await close.count()) await close.first().click();
|
||||
assert.equal(await page.locator('.results-chart-panel svg[data-result-chart="true"]').count(), 0,
|
||||
'Curve timing requires no existing chart; update close-window selector if the UI changed.');
|
||||
await arm(page, 'temperatureClick', '.results-variable-list button');
|
||||
await watch(page, 'curve', '.results-chart-panel svg[data-result-chart="true"]');
|
||||
await page.locator('.results-variable-list button').filter({ has: page.locator('small', { hasText: /^K$/ }) }).first().click();
|
||||
await waitMark(page, 'curveStable');
|
||||
await waitMark(page, 'indexedDbPointerObserved');
|
||||
const saveDownload = async (buttonName, clickName, completedName, filename) => {
|
||||
await arm(page, clickName, 'button');
|
||||
const pending = page.waitForEvent('download', { timeout: 180000 });
|
||||
await page.getByRole('button', { name: buttonName, exact: true }).click();
|
||||
const download = await pending;
|
||||
await download.saveAs(path.join(runDir, filename));
|
||||
await pageMark(page, completedName);
|
||||
assert.equal(await download.failure(), null);
|
||||
};
|
||||
await saveDownload('下载结果 CSV', 'csvClick', 'csvDownloadSaved', 'result.csv');
|
||||
await saveDownload('下载结果文件', 'resultFileClick', 'resultFileDownloadSaved', 'result.simresult');
|
||||
const trace = await page.evaluate(() => window.__stageProfile.snapshot());
|
||||
if (cpuRecorder) {
|
||||
const capture = await cpuRecorder.stop();
|
||||
const file = path.join(runDir, 'interaction.cpuprofile');
|
||||
await fs.writeFile(file, JSON.stringify(capture.profile));
|
||||
cpuRecords.push({ ...capture, trace, kind: 'interaction', file });
|
||||
}
|
||||
const exportedBytes = await fs.readFile(path.join(runDir, 'result.simresult'));
|
||||
const exported = JSON.parse(exportedBytes);
|
||||
const result = exported.snapshot.result;
|
||||
assert.equal(result.success, true, result.message);
|
||||
assert.equal(result.simulatedUntil, Number(project.simulation.t_stop));
|
||||
const expectedDigest = resultDigest(result);
|
||||
const csv = await fs.readFile(path.join(runDir, 'result.csv'));
|
||||
await fs.writeFile(path.join(runDir, 'trace.json'), JSON.stringify(trace, null, 2));
|
||||
await page.screenshot({ path: path.join(runDir, 'result.png'), fullPage: true });
|
||||
// Reload the real persisted snapshot without changing its session pointer or result.
|
||||
if (cpuRecorder) await cpuRecorder.start();
|
||||
await page.reload();
|
||||
await page.evaluate(() => { window.__stageProfile.activeRun = true; });
|
||||
await waitMark(page, 'restoredResultsStable');
|
||||
await page.getByRole('button', { name: '下载结果文件', exact: true }).waitFor();
|
||||
if (cpuRecorder) {
|
||||
const restoreCpuTrace = await page.evaluate(() => window.__stageProfile.snapshot());
|
||||
const capture = await cpuRecorder.stop();
|
||||
const file = path.join(runDir, 'restore.cpuprofile');
|
||||
await fs.writeFile(file, JSON.stringify(capture.profile));
|
||||
cpuRecords.push({ ...capture, trace: restoreCpuTrace, kind: 'restore', file });
|
||||
}
|
||||
const restoredDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: '下载结果文件', exact: true }).click();
|
||||
await (await restoredDownload).saveAs(path.join(runDir, 'restored.simresult'));
|
||||
const restoredBytes = await fs.readFile(path.join(runDir, 'restored.simresult'));
|
||||
const restored = JSON.parse(restoredBytes);
|
||||
assert.equal(resultDigest(restored.snapshot.result), expectedDigest, 'Restored result changed.');
|
||||
const restoreTrace = await page.evaluate(() => ({ ...window.__stageProfile.snapshot(),
|
||||
navigation: performance.getEntriesByType('navigation').map(e => e.toJSON()) }));
|
||||
await fs.writeFile(path.join(runDir, 'restore-trace.json'), JSON.stringify(restoreTrace, null, 2));
|
||||
const requests = simulationRequests.slice(requestOffset);
|
||||
assert.equal(requests.length, 1, 'Expected exactly one real simulation request.');
|
||||
assert.ok(requests[0].simulationId, 'Missing X-Simulation-Id correlation key.');
|
||||
const row = { mode, deep: Boolean(options.deep), run, warmup: run === 0, simulationId: requests[0].simulationId, timeOrigin: trace.timeOrigin, ...metrics(trace),
|
||||
restoreNavigationToDomMs: restoreTrace.marks.restoredResultsDom,
|
||||
restoreNavigationToPaintOpportunityMs: restoreTrace.marks.restoredResultsPaintOpportunity,
|
||||
restoreNavigationToDomStableMs: restoreTrace.marks.restoredResultsStable,
|
||||
sampleCount: result.series.time.length, variableCount: result.variables.length,
|
||||
native: result.diagnostics.native, integration: result.diagnostics.integration,
|
||||
resultSha256: sha(exportedBytes), numericalResultSha256: expectedDigest, resultBytes: exportedBytes.length,
|
||||
csvSha256: sha(csv), csvBytes: csv.length, restoredIdentical: true, artifacts: prefix };
|
||||
rows.push(row);
|
||||
await writeSummary();
|
||||
console.log(JSON.stringify(row));
|
||||
if (errors.length) throw new Error(`Browser errors: ${JSON.stringify(errors)}`);
|
||||
}
|
||||
// Collect actual worker build evidence after all timing intervals. Worker code
|
||||
// is loaded dynamically and does not appear among the initial document tags.
|
||||
for (const url of workerUrls) {
|
||||
if (!/^https?:/.test(url)) continue;
|
||||
const response = await context.request.get(url);
|
||||
assert.ok(response.ok(), `Worker asset HTTP ${response.status()}: ${url}`);
|
||||
const bytes = await response.body();
|
||||
const previous = evidence.servedAssets.find(asset => asset.url === url);
|
||||
if (previous) assert.equal(previous.sha256, sha(bytes), 'Worker build changed between modes.');
|
||||
else evidence.servedAssets.push({ url, sha256: sha(bytes), bytes: bytes.length });
|
||||
}
|
||||
evidence.buildAssetSetSha256 = sha(JSON.stringify(evidence.servedAssets
|
||||
.map(({ url, ...asset }) => ({ path: new URL(url).pathname, ...asset }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path))));
|
||||
await writeSummary();
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(options.output, `${mode}-failure.png`), fullPage: true }).catch(() => {});
|
||||
const trace = await page.evaluate(() => window.__stageProfile?.snapshot()).catch(() => null);
|
||||
const body = await page.locator('body').innerText().catch(() => null);
|
||||
await fs.writeFile(path.join(options.output, `${mode}-failure.json`), JSON.stringify({ error: error.stack, trace, errors, body }, null, 2));
|
||||
throw error;
|
||||
} finally { if (cpuRecorder) await cpuRecorder.close(); await context.close(); }
|
||||
}
|
||||
if (cpuRecords.length) {
|
||||
const { consumers, evidence: sourceMaps } = await loadVerifiedCpuMaps(options.sourceMapDir, evidence.servedAssets);
|
||||
evidence.cpuDiagnostics = { sourceMaps, profiles: [] };
|
||||
for (const record of cpuRecords) {
|
||||
const summary = summarizeCpu(record, consumers);
|
||||
const output = record.file.replace(/\.cpuprofile$/, '-cpu-summary.json');
|
||||
await fs.writeFile(output, JSON.stringify(summary, null, 2));
|
||||
await fs.writeFile(output.replace(/\.json$/, '.md'), cpuSummaryMarkdown(summary, record.file));
|
||||
evidence.cpuDiagnostics.profiles.push({ profile: record.file, summary: output, kind: record.kind });
|
||||
}
|
||||
}
|
||||
const median = values => { const v = values.filter(n => typeof n === 'number').sort((a, b) => a - b);
|
||||
return v.length ? v.length % 2 ? v[Math.floor(v.length / 2)] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2 : null; };
|
||||
evidence.measuredMedians = Object.fromEntries(['control', 'profiled'].map(mode => [mode,
|
||||
Object.fromEntries(Object.keys(rows.find(r => r.mode === mode) ?? {}).filter(k => k.endsWith('Ms'))
|
||||
.map(k => [k, median(rows.filter(r => r.mode === mode && !r.warmup).map(r => r[k]))]))]));
|
||||
await writeSummary();
|
||||
} finally { await writeSummary(); await browser.close(); }
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Compare all real browser CSV/result/restore values with a native execution.
|
||||
|
||||
Run only after performance measurements finish; JSON/CSV verification is CPU intensive.
|
||||
No tolerance or resampling is used. Diagnostic timing fields intentionally differ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('directory', type=Path)
|
||||
parser.add_argument('--native', type=Path, help='Full native result baseline; defaults to DIRECTORY/native-production/run-1/result.json')
|
||||
parser.add_argument('--group', action='append', default=[], metavar='LABEL=PATH', help='Explicit browser output group; repeat for old/new/profiled groups')
|
||||
parser.add_argument('--output', type=Path, help='Defaults to DIRECTORY/equality.json')
|
||||
args = parser.parse_args()
|
||||
base = args.directory
|
||||
native_path = args.native or base / 'native-production/run-1/result.json'
|
||||
groups = []
|
||||
for group in args.group:
|
||||
label, separator, directory = group.partition('=')
|
||||
if not separator or not label or not directory:
|
||||
parser.error('--group requires LABEL=PATH')
|
||||
groups.append((label, Path(directory)))
|
||||
if not groups:
|
||||
groups = [(mode, base / f'browser-{mode}') for mode in ('control', 'profiled')]
|
||||
assert len({label for label, _ in groups}) == len(groups), 'Group labels must be unique'
|
||||
native = json.loads(native_path.read_text())
|
||||
assert native['success'] and native['simulatedUntil'] == 10
|
||||
expected_series, expected_final = native['series'], native['final']
|
||||
point_count = len(expected_series['time'])
|
||||
assert len(expected_series) == 1785 and point_count == 1002
|
||||
report = {
|
||||
'native': str(native_path), 'nativeSha256': sha256(native_path),
|
||||
'comparison': 'Numeric equality using == on parsed numbers, with identical keys and lengths; no tolerance, conversion, interpolation or rounding. Diagnostic timings are excluded.',
|
||||
'sampleCount': point_count, 'seriesCountIncludingTime': len(expected_series),
|
||||
'finalValueCount': len(expected_final), 'runs': [], 'groups': [],
|
||||
}
|
||||
input_hashes, build_hashes, csv_hashes = set(), set(), set()
|
||||
for label, group in groups:
|
||||
group_csv_hashes = set()
|
||||
summary = json.loads((group / 'summary.json').read_text())
|
||||
assert not summary['errors'], summary['errors']
|
||||
assert len(summary['rows']) == 4
|
||||
assert sum(bool(row['warmup']) for row in summary['rows']) == 1
|
||||
assert len({row['run'] for row in summary['rows']}) == 4
|
||||
input_hashes.add(summary['inputSha256'])
|
||||
build_hashes.add(summary['buildAssetSetSha256'])
|
||||
for row in summary['rows']:
|
||||
directory = group / row['artifacts']
|
||||
result_path = directory / 'result.simresult'
|
||||
result = json.loads(result_path.read_text())['snapshot']['result']
|
||||
assert result['success'] and result['simulatedUntil'] == 10
|
||||
assert set(result['series']) == set(expected_series), directory
|
||||
assert result['series'] == expected_series, f'{directory}: series differs from native'
|
||||
assert result['final'] == expected_final, f'{directory}: final differs from native'
|
||||
variables = [variable['key'] for variable in result['variables']]
|
||||
assert len(variables) == 1784 and len(set(variables)) == 1784
|
||||
restored_path = directory / 'restored.simresult'
|
||||
restored = json.loads(restored_path.read_text())['snapshot']['result']
|
||||
assert restored == result, f'{directory}: restored result changed'
|
||||
del restored
|
||||
csv_path = directory / 'result.csv'
|
||||
checked = 0
|
||||
with csv_path.open(newline='', encoding='utf-8-sig') as source:
|
||||
reader = csv.reader(source)
|
||||
headers = next(reader)
|
||||
assert headers == ['time', *variables], f'{directory}: CSV variable order differs'
|
||||
assert len(headers) == 1785 and set(headers) == set(expected_series)
|
||||
columns = [expected_series[key] for key in headers]
|
||||
row_count = 0
|
||||
for index, cells in enumerate(reader):
|
||||
assert index < point_count and len(cells) == len(headers), (directory, index)
|
||||
for column_index, (cell, column) in enumerate(zip(cells, columns, strict=True)):
|
||||
assert float(cell) == column[index], (directory, index, headers[column_index], cell, column[index])
|
||||
checked += 1
|
||||
row_count += 1
|
||||
assert row_count == point_count
|
||||
csv_hash = sha256(csv_path)
|
||||
csv_hashes.add(csv_hash)
|
||||
group_csv_hashes.add(csv_hash)
|
||||
item = {
|
||||
'group': label, 'mode': row['mode'], 'run': row['run'], 'warmup': row['warmup'],
|
||||
'simulationId': row['simulationId'], 'directory': str(directory),
|
||||
'seriesComparedValues': sum(map(len, expected_series.values())),
|
||||
'finalComparedValues': len(expected_final), 'csvComparedCells': checked,
|
||||
'csvColumns': len(headers), 'csvRows': row_count,
|
||||
'seriesExactlyEqualToNative': True, 'finalExactlyEqualToNative': True,
|
||||
'csvAllCellsExactlyEqualToNative': True, 'restoredResultExactlyEqual': True,
|
||||
'resultSha256': sha256(result_path), 'restoredSha256': sha256(restored_path),
|
||||
'csvSha256': csv_hash,
|
||||
}
|
||||
report['runs'].append(item)
|
||||
print(json.dumps({'group': label, 'mode': row['mode'], 'run': row['run'], 'checkedCsvCells': checked, 'exact': True}), flush=True)
|
||||
assert len(group_csv_hashes) == 1, f'{label}: CSV bytes differ between repeated runs'
|
||||
report['groups'].append({
|
||||
'label': label, 'directory': str(group), 'runCount': len(summary['rows']),
|
||||
'buildAssetSetSha256': summary['buildAssetSetSha256'],
|
||||
'inputSha256': summary['inputSha256'], 'allCsvFilesByteIdentical': True,
|
||||
'csvSha256': next(iter(group_csv_hashes)),
|
||||
})
|
||||
assert len(input_hashes) == 1
|
||||
# Python and JavaScript emit different round-tripping spellings (e.g. 0.0/0).
|
||||
# Across versions CSV numeric equality is required; text hashes are per group.
|
||||
report.update({
|
||||
'allPassed': True, 'inputSha256': next(iter(input_hashes)),
|
||||
'buildAssetSetSha256': next(iter(build_hashes)) if len(build_hashes) == 1 else None,
|
||||
'buildAssetSetSha256Values': sorted(build_hashes),
|
||||
'allGroupsCsvFilesByteIdentical': True,
|
||||
'allCsvFilesByteIdentical': len(csv_hashes) == 1,
|
||||
'csvSha256': next(iter(csv_hashes)) if len(csv_hashes) == 1 else None,
|
||||
'totalCsvCellsCompared': sum(row['csvComparedCells'] for row in report['runs']),
|
||||
'totalSeriesValuesCompared': sum(row['seriesComparedValues'] for row in report['runs']),
|
||||
'totalFinalValuesCompared': sum(row['finalComparedValues'] for row in report['runs']),
|
||||
})
|
||||
output = args.output or base / 'equality.json'
|
||||
output.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(json.dumps({'output': str(output), 'allPassed': True, 'totalCsvCellsCompared': report['totalCsvCellsCompared']}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Compare complete native numerical results as binary64, outside benchmark timing.
|
||||
|
||||
.venv/bin/python tests/manual/compare_native_result_bits.py \
|
||||
--baseline old/result.json --candidate new/run-1/result.json \
|
||||
--candidate new/run-2/result.json --output test/result-bit-parity.json
|
||||
|
||||
JSON number spelling may change. In particular, the integer token -0 must be
|
||||
parsed as negative floating zero before packing. All series columns (including
|
||||
time), final scalars and finalState entries are compared without sampling.
|
||||
Solver status/configuration/counters must also agree. Only solve wall/CPU timing
|
||||
metadata is intentionally ignored. Nonfinite payload or metadata numbers fail.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PAYLOAD_KEYS = ("series", "final", "finalState")
|
||||
FLOAT_METADATA = ("simulatedUntil", "maxAcceptedStep")
|
||||
COUNT_METADATA = ("nfev", "acceptedSteps", "rejectedSteps", "njev", "nlu", "stateTransitions", "solverStarts")
|
||||
VALUE_METADATA = ("success", "status", "message", "method", "backend", "solver", "sundialsVersion")
|
||||
TIMING_METADATA = ("solveSeconds", "solveCpuSeconds")
|
||||
NEGATIVE_ZERO = struct.pack("<Q", 1 << 63)
|
||||
POSITIVE_ZERO = b"\0" * 8
|
||||
|
||||
|
||||
class ComparisonError(ValueError):
|
||||
def __init__(self, path: str, reason: str, **details: object) -> None:
|
||||
super().__init__(f"{path}: {reason}")
|
||||
self.detail = {"path": path, "reason": reason, **details}
|
||||
|
||||
|
||||
def pointer(*parts: object) -> str:
|
||||
return "/" + "/".join(str(part).replace("~", "~0").replace("/", "~1") for part in parts)
|
||||
|
||||
|
||||
def read_result(path: Path) -> tuple[dict, str]:
|
||||
raw = path.read_bytes()
|
||||
|
||||
def unique_object(items: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result:
|
||||
raise ComparisonError("/", "Duplicate JSON object key", key=key)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def reject_constant(token: str) -> object:
|
||||
raise ComparisonError("/", "Nonfinite JSON token", token=token)
|
||||
|
||||
value = json.loads(raw, parse_int=lambda token: -0.0 if token == "-0" else int(token),
|
||||
parse_constant=reject_constant, object_pairs_hook=unique_object)
|
||||
if not isinstance(value, dict):
|
||||
raise ComparisonError("/", "Expected a native result object")
|
||||
return value, sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def bits(value: object, path: str) -> bytes:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ComparisonError(path, "Expected a finite numeric value", actualType=type(value).__name__)
|
||||
try:
|
||||
number = float(value)
|
||||
except (OverflowError, ValueError):
|
||||
raise ComparisonError(path, "Number cannot be represented as finite binary64") from None
|
||||
if not math.isfinite(number):
|
||||
raise ComparisonError(path, "Nonfinite binary64 value")
|
||||
return struct.pack("<d", number)
|
||||
|
||||
|
||||
def ensure_finite_tree(value: object, path: str = "") -> None:
|
||||
"""Reject overflow-to-infinity tokens even in metadata excluded from parity."""
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
ensure_finite_tree(child, path + pointer(key))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
ensure_finite_tree(child, path + pointer(index))
|
||||
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
bits(value, path or "/")
|
||||
|
||||
|
||||
def validate_result(result: dict) -> dict:
|
||||
required = set(PAYLOAD_KEYS + FLOAT_METADATA + COUNT_METADATA + VALUE_METADATA)
|
||||
if missing := required - result.keys():
|
||||
raise ComparisonError("/", "Missing native result fields", missing=sorted(missing))
|
||||
if not isinstance(result["series"], dict) or not isinstance(result["final"], dict):
|
||||
raise ComparisonError("/", "series and final must be objects")
|
||||
if not isinstance(result["finalState"], list):
|
||||
raise ComparisonError("/finalState", "Expected an array")
|
||||
for key, values in result["series"].items():
|
||||
if not isinstance(values, list):
|
||||
raise ComparisonError(pointer("series", key), "Expected a numeric array")
|
||||
if result["series"] and "time" not in result["series"]:
|
||||
raise ComparisonError("/series", "Nonempty series has no time column")
|
||||
if result["series"]:
|
||||
samples = len(result["series"]["time"])
|
||||
for key, values in result["series"].items():
|
||||
if len(values) != samples:
|
||||
raise ComparisonError(pointer("series", key), "Column length differs from time", expectedLength=samples, actualLength=len(values))
|
||||
for key in COUNT_METADATA:
|
||||
value = result[key]
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ComparisonError(pointer(key), "Expected a nonnegative integer solver counter")
|
||||
if not isinstance(result["success"], bool):
|
||||
raise ComparisonError("/success", "Expected a boolean")
|
||||
for key in VALUE_METADATA[1:]:
|
||||
if not isinstance(result[key], str):
|
||||
raise ComparisonError(pointer(key), "Expected string metadata")
|
||||
ensure_finite_tree(result)
|
||||
for key in FLOAT_METADATA:
|
||||
bits(result[key], pointer(key))
|
||||
# Scalars in final and all payload cells must be numeric, never bool/null.
|
||||
count = negative_zeroes = positive_zeroes = 0
|
||||
for path, value in payload_values(result):
|
||||
packed = bits(value, path)
|
||||
count += 1
|
||||
negative_zeroes += packed == NEGATIVE_ZERO
|
||||
positive_zeroes += packed == POSITIVE_ZERO
|
||||
return {"payloadValues": count, "seriesColumns": len(result["series"]),
|
||||
"samples": len(result["series"].get("time", [])), "finalScalars": len(result["final"]),
|
||||
"finalStateValues": len(result["finalState"]), "negativeZeroValues": negative_zeroes,
|
||||
"positiveZeroValues": positive_zeroes}
|
||||
|
||||
|
||||
def payload_values(result: dict):
|
||||
for key, values in result["series"].items():
|
||||
for index, value in enumerate(values):
|
||||
yield pointer("series", key, index), value
|
||||
for key, value in result["final"].items():
|
||||
yield pointer("final", key), value
|
||||
for index, value in enumerate(result["finalState"]):
|
||||
yield pointer("finalState", index), value
|
||||
|
||||
|
||||
def match_keys(baseline: dict, candidate: dict, path: str) -> None:
|
||||
if baseline.keys() != candidate.keys():
|
||||
raise ComparisonError(path, "Object key sets differ", missing=sorted(baseline.keys() - candidate.keys()),
|
||||
extra=sorted(candidate.keys() - baseline.keys()))
|
||||
|
||||
|
||||
def compare(baseline: dict, candidate: dict) -> dict:
|
||||
# Validate the complete structure before comparing any payload bit patterns.
|
||||
match_keys(baseline, candidate, "/")
|
||||
for key in ("series", "final"):
|
||||
match_keys(baseline[key], candidate[key], pointer(key))
|
||||
for key, values in baseline["series"].items():
|
||||
if len(values) != len(candidate["series"][key]):
|
||||
raise ComparisonError(pointer("series", key), "Array lengths differ", baselineLength=len(values), candidateLength=len(candidate["series"][key]))
|
||||
if len(baseline["finalState"]) != len(candidate["finalState"]):
|
||||
raise ComparisonError("/finalState", "Array lengths differ", baselineLength=len(baseline["finalState"]), candidateLength=len(candidate["finalState"]))
|
||||
metadata_comparisons = 0
|
||||
for key in VALUE_METADATA + COUNT_METADATA:
|
||||
if baseline[key] != candidate[key]:
|
||||
raise ComparisonError(pointer(key), "Solver metadata or counter differs", baseline=baseline[key], candidate=candidate[key])
|
||||
metadata_comparisons += 1
|
||||
for key in FLOAT_METADATA:
|
||||
left, right = bits(baseline[key], pointer(key)), bits(candidate[key], pointer(key))
|
||||
if left != right:
|
||||
raise ComparisonError(pointer(key), "Numeric metadata binary64 bits differ", baselineBitsLE=left.hex(), candidateBitsLE=right.hex())
|
||||
metadata_comparisons += 1
|
||||
# Any future top-level metadata field must also agree unless explicitly timed.
|
||||
known = set(PAYLOAD_KEYS + FLOAT_METADATA + COUNT_METADATA + VALUE_METADATA + TIMING_METADATA)
|
||||
for key in baseline.keys() - known:
|
||||
if baseline[key] != candidate[key]:
|
||||
raise ComparisonError(pointer(key), "Additional metadata differs")
|
||||
metadata_comparisons += 1
|
||||
comparisons = negative_zeroes = positive_zeroes = 0
|
||||
|
||||
def compare_number(left_value: object, right_value: object, path: str) -> None:
|
||||
nonlocal comparisons, negative_zeroes, positive_zeroes
|
||||
left, right = bits(left_value, path), bits(right_value, path)
|
||||
if left != right:
|
||||
raise ComparisonError(path, "Payload binary64 bits differ", baselineBitsLE=left.hex(), candidateBitsLE=right.hex(),
|
||||
baselineValue=repr(left_value), candidateValue=repr(right_value),
|
||||
signedZeroMismatch=left in (POSITIVE_ZERO, NEGATIVE_ZERO) and right in (POSITIVE_ZERO, NEGATIVE_ZERO))
|
||||
comparisons += 1
|
||||
negative_zeroes += left == NEGATIVE_ZERO
|
||||
positive_zeroes += left == POSITIVE_ZERO
|
||||
|
||||
# Look up columns by their validated key; JSON object order is immaterial.
|
||||
for key, values in baseline["series"].items():
|
||||
for index, (left, right) in enumerate(zip(values, candidate["series"][key], strict=True)):
|
||||
compare_number(left, right, pointer("series", key, index))
|
||||
for key, value in baseline["final"].items():
|
||||
compare_number(value, candidate["final"][key], pointer("final", key))
|
||||
for index, (left, right) in enumerate(zip(baseline["finalState"], candidate["finalState"], strict=True)):
|
||||
compare_number(left, right, pointer("finalState", index))
|
||||
return {"passed": True, "comparisons": comparisons, "metadataComparisons": metadata_comparisons,
|
||||
"negativeZeroComparisons": negative_zeroes, "positiveZeroComparisons": positive_zeroes,
|
||||
"allPayloadBinary64BitsEqual": True}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--baseline", required=True, type=Path)
|
||||
parser.add_argument("--candidate", required=True, action="append", type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.output.resolve() in {args.baseline.resolve(), *(path.resolve() for path in args.candidate)}:
|
||||
parser.error("--output must differ from every input file")
|
||||
report = {"version": 1, "baseline": str(args.baseline.resolve()), "candidates": [], "allPassed": False,
|
||||
"comparisonContract": "Exact finite binary64 payload bits, including signed zero; complete structure plus solver metadata/counters. JSON object ordering is ignored. solveSeconds and solveCpuSeconds are excluded. Parsing and comparison are diagnostic work outside benchmark timing."}
|
||||
try:
|
||||
baseline, baseline_hash = read_result(args.baseline)
|
||||
report["baselineSha256"] = baseline_hash
|
||||
report["baselineStatistics"] = validate_result(baseline)
|
||||
for path in args.candidate:
|
||||
item = {"path": str(path.resolve()), "passed": False}
|
||||
try:
|
||||
candidate, candidate_hash = read_result(path)
|
||||
item["sha256"] = candidate_hash
|
||||
item["statistics"] = validate_result(candidate)
|
||||
item.update(compare(baseline, candidate))
|
||||
except ComparisonError as error:
|
||||
item["error"] = error.detail
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
item["error"] = {"reason": str(error), "type": type(error).__name__}
|
||||
report["candidates"].append(item)
|
||||
report["allPassed"] = all(item["passed"] for item in report["candidates"])
|
||||
except ComparisonError as error:
|
||||
report["baselineError"] = error.detail
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
report["baselineError"] = {"reason": str(error), "type": type(error).__name__}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"allPassed": report["allPassed"], "candidates": len(report["candidates"]), "output": str(args.output.resolve())}))
|
||||
return 0 if report["allPassed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Isolated, Linux/GCC-only CVODE cost diagnostic; never a production benchmark.
|
||||
|
||||
Example (prepare only by default; --run builds and executes serially)::
|
||||
|
||||
.venv/bin/python tests/manual/native_compute_profile.py \
|
||||
--cache-dir test/.../cache/BUILD_KEY --request-stages test/.../stages.json \
|
||||
--output-dir test/native-compute-profile --run --warmups 1 --repeats 3
|
||||
|
||||
The cached executable is the unmodified control. Only a private native source
|
||||
copy receives wall-clock scopes and sparse CVODE counter reads. The generated
|
||||
model and numerical expressions, compiler FP flags, solver and libraries stay
|
||||
unchanged. Full output/state/counter equality is checked outside run timing.
|
||||
Inclusive durations are nested: only exclusiveSeconds may be added. Clock and
|
||||
bookkeeping overhead remain in measured totals; compare against the control.
|
||||
No property/pipe/libc allocation is inferred from this outer-only diagnostic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.simulation.native_codegen.build import LIBRARIES, toolchain
|
||||
|
||||
CATEGORIES = ["integration", "cvode_step", "rhs", "poll", "accept", "append", "dense_output", "dense_setup", "dense_solve"]
|
||||
COUNTERS = ["rhs", "linear_rhs", "nonlinear_iterations", "nonlinear_failures"]
|
||||
PROFILE_HEADER = r'''
|
||||
#ifndef NATIVE_COMPUTE_PROFILE_H
|
||||
#define NATIVE_COMPUTE_PROFILE_H
|
||||
#include <stddef.h>
|
||||
enum { @CATEGORIES@, PROFILE_CATEGORY_COUNT };
|
||||
typedef struct ProfileScope { double start, children; int id, domain, active; struct ProfileScope *parent; } ProfileScope;
|
||||
ProfileScope profile_begin(int id);
|
||||
void profile_link(ProfileScope *scope);
|
||||
void profile_end(ProfileScope *scope);
|
||||
void profile_counter(int slot, int status, long int value);
|
||||
void profile_counter_segment(void);
|
||||
void profile_dump(void);
|
||||
#define PROFILE_SCOPE(id) ProfileScope profile_scope __attribute__((cleanup(profile_end)))=profile_begin(id); profile_link(&profile_scope)
|
||||
#endif
|
||||
'''
|
||||
PROFILE_SOURCE = r'''
|
||||
#include "compute_profile.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdint.h>
|
||||
typedef struct { unsigned long long count; double inclusive, exclusive; } ProfileTotal;
|
||||
static ProfileTotal totals[2][PROFILE_CATEGORY_COUNT];
|
||||
static ProfileScope *parent;
|
||||
static unsigned long long counters[4], segments, counter_errors;
|
||||
static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC,&t); return t.tv_sec+t.tv_nsec*1e-9; }
|
||||
ProfileScope profile_begin(int id) {
|
||||
ProfileScope s={0}; s.id=id; s.domain=(id==PROFILE_INTEGRATION || (parent && parent->domain));
|
||||
s.parent=parent; s.active=1; s.start=now(); return s;
|
||||
}
|
||||
void profile_link(ProfileScope *s) { parent=s; }
|
||||
void profile_end(ProfileScope *s) {
|
||||
if(!s->active) return;
|
||||
double elapsed=now()-s->start;
|
||||
if(parent!=s) { fputs("Invalid profile scope nesting\n",stderr); exit(74); }
|
||||
ProfileTotal *t=&totals[s->domain][s->id];
|
||||
t->count++; t->inclusive+=elapsed; t->exclusive+=elapsed-s->children;
|
||||
parent=s->parent;
|
||||
if(parent) parent->children+=elapsed;
|
||||
s->active=0;
|
||||
}
|
||||
void profile_counter(int slot,int status,long int value) {
|
||||
if(status || value<0) counter_errors++; else counters[slot]+=(unsigned long long)value;
|
||||
}
|
||||
void profile_counter_segment(void) { segments++; }
|
||||
void profile_dump(void) {
|
||||
const char *path=getenv("NATIVE_COMPUTE_PROFILE"); if(!path)return;
|
||||
FILE *f=fopen(path,"wb"); if(!f){perror(path);exit(73);}
|
||||
const char *names[]={@NAMES@};
|
||||
fprintf(f,"{\"version\":1,\"counterSegments\":%llu,\"counterErrors\":%llu,\"cvodeCounters\":{",segments,counter_errors);
|
||||
const char *counter_names[]={"rhs","linear_rhs","nonlinear_iterations","nonlinear_failures"};
|
||||
for(int i=0;i<4;i++)fprintf(f,"%s\"%s\":%llu",i?",":"",counter_names[i],counters[i]);
|
||||
fprintf(f,"},\"scopes\":{");
|
||||
for(int d=0;d<2;d++) {
|
||||
fprintf(f,"%s\"%s\":{",d?",":"",d?"integration":"outsideIntegration");
|
||||
for(int i=0;i<PROFILE_CATEGORY_COUNT;i++) {
|
||||
ProfileTotal *t=&totals[d][i];
|
||||
fprintf(f,"%s\"%s\":{\"calls\":%llu,\"inclusiveSeconds\":%.17g,\"exclusiveSeconds\":%.17g}",
|
||||
i?",":"",names[i],t->count,t->inclusive,t->exclusive);
|
||||
}
|
||||
fputc('}',f);
|
||||
}
|
||||
fprintf(f,"}}\n"); int ok=!ferror(f); if(fclose(f))ok=0; if(!ok)exit(73);
|
||||
}
|
||||
'''
|
||||
LINEAR_WRAPPERS = r'''
|
||||
/* Preserve the exact original Dense ops; only the call boundary is timed. */
|
||||
static int (*profile_original_setup)(SUNLinearSolver,SUNMatrix);
|
||||
static int (*profile_original_solve)(SUNLinearSolver,SUNMatrix,N_Vector,N_Vector,sunrealtype);
|
||||
static int profile_dense_setup(SUNLinearSolver linear,SUNMatrix matrix) {
|
||||
PROFILE_SCOPE(PROFILE_DENSE_SETUP);
|
||||
return profile_original_setup(linear,matrix);
|
||||
}
|
||||
static int profile_dense_solve(SUNLinearSolver linear,SUNMatrix matrix,N_Vector x,N_Vector b,sunrealtype tolerance) {
|
||||
PROFILE_SCOPE(PROFILE_DENSE_SOLVE);
|
||||
return profile_original_solve(linear,matrix,x,b,tolerance);
|
||||
}
|
||||
static int profile_cvode(void *solver,sunrealtype end,N_Vector y,sunrealtype *next,int task) {
|
||||
PROFILE_SCOPE(PROFILE_CVODE_STEP);
|
||||
return CVode(solver,end,y,next,task);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def replace_once(text: str, old: str, new: str) -> str:
|
||||
if text.count(old) != 1:
|
||||
raise RuntimeError(f"Source anchor count changed: {old!r}")
|
||||
return text.replace(old, new)
|
||||
|
||||
|
||||
def scope_function(text: str, function: str, category: str) -> str:
|
||||
pattern = rf"(?m)^[A-Za-z_][A-Za-z0-9_ \t*]*\b{re.escape(function)}\s*\([^;{{}}]*\)\s*\{{"
|
||||
matches = list(re.finditer(pattern, text))
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"Cannot identify unique function {function}")
|
||||
pos = matches[0].end()
|
||||
return text[:pos] + f"\n PROFILE_SCOPE(PROFILE_{category.upper()});" + text[pos:]
|
||||
|
||||
|
||||
def instrument(native: Path) -> None:
|
||||
(native / "include/compute_profile.h").write_text(PROFILE_HEADER.replace("@CATEGORIES@", ", ".join("PROFILE_" + c.upper() for c in CATEGORIES)))
|
||||
(native / "runtime/compute_profile.c").write_text(PROFILE_SOURCE.replace("@NAMES@", ",".join(json.dumps(c) for c in CATEGORIES)))
|
||||
for filename, functions in {
|
||||
"common.c": {"native_rhs": "rhs", "native_poll": "poll", "native_accept": "accept", "native_append": "append"},
|
||||
"cvode_solver.c": {"native_bdf": "integration", "cv_dense": "dense_output"},
|
||||
"rk45.c": {"native_rk45": "integration"},
|
||||
}.items():
|
||||
path = native / "runtime" / filename
|
||||
text = '#include "compute_profile.h"\n' + path.read_text()
|
||||
for function, category in functions.items():
|
||||
text = scope_function(text, function, category)
|
||||
if filename == "cvode_solver.c":
|
||||
text = replace_once(text, "typedef struct { void *solver;", LINEAR_WRAPPERS + "\ntypedef struct { void *solver;")
|
||||
text = replace_once(text, " if (!linear) goto cleanup;", " if (!linear) goto cleanup;\n profile_original_setup=linear->ops->setup; profile_original_solve=linear->ops->solve;\n linear->ops->setup=profile_dense_setup; linear->ops->solve=profile_dense_solve;")
|
||||
text = replace_once(text, "int flag=CVode(solver,end,y,&next,CV_ONE_STEP);", "int flag=profile_cvode(solver,end,y,&next,CV_ONE_STEP);")
|
||||
extra = "\n profile_counter_segment();\n"
|
||||
for slot, api in enumerate(("CVodeGetNumRhsEvals", "CVodeGetNumLinRhsEvals", "CVodeGetNumNonlinSolvIters", "CVodeGetNumNonlinSolvConvFails")):
|
||||
extra += f" value=0; int profile_status_{slot}={api}(solver,&value); profile_counter({slot},profile_status_{slot},value);\n"
|
||||
text = replace_once(text, "CVodeGetNumLinSolvSetups(solver,&value); r->nlu+=(unsigned long)value;", "CVodeGetNumLinSolvSetups(solver,&value); r->nlu+=(unsigned long)value;" + extra)
|
||||
path.write_text(text)
|
||||
path = native / "runtime/main.c"
|
||||
text = '#include "compute_profile.h"\n' + path.read_text()
|
||||
text = replace_once(text, " native_run_free(&r); return code;", " native_run_free(&r); profile_dump(); return code;")
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def runtime_arguments(stages: Path | None) -> list[str]:
|
||||
if stages:
|
||||
original = json.loads(stages.read_text())["process"]["command"]
|
||||
args = original[1:]
|
||||
else:
|
||||
args = ["--method", "BDF", "--start", "0", "--stop", "10", "--sample-step", ".01", "--max-step", "1e30", "--rtol", "1e-8", "--timeout", "300"]
|
||||
safe, index = [], 0
|
||||
value_options = {"--method", "--start", "--stop", "--sample-step", "--max-step", "--rtol", "--timeout"}
|
||||
while index < len(args):
|
||||
key = args[index]
|
||||
if key == "--solve-only":
|
||||
safe.append(key); index += 1; continue
|
||||
if key not in value_options | {"--output", "--result-index", "--cancel-file"} or index + 1 >= len(args):
|
||||
raise RuntimeError(f"Unsupported replay argument: {key}")
|
||||
if key in value_options:
|
||||
safe.extend(args[index:index + 2])
|
||||
index += 2
|
||||
return safe
|
||||
|
||||
|
||||
def prepare(args: argparse.Namespace) -> dict:
|
||||
cache, output = args.cache_dir.resolve(), args.output_dir.resolve()
|
||||
if not output.is_relative_to(ROOT / "test"):
|
||||
raise RuntimeError("Diagnostic output must be in the repository's ignored test/ directory")
|
||||
manifest = json.loads((cache / "manifest.json").read_text())
|
||||
for name in ("model", "model.c", "model.h"):
|
||||
if digest(cache / name) != manifest["artifacts"][name]:
|
||||
raise RuntimeError(f"Cache artifact integrity failure: {name}")
|
||||
# Reject numerical/runtime drift; a sparse timing-only cached main is allowed
|
||||
# because control and our current writer share the numeric model contract.
|
||||
differences = []
|
||||
for name, expected in manifest["sourceHashes"].items():
|
||||
relative = name.split("native/", 1)[-1]
|
||||
current = ROOT / "native" / relative
|
||||
if digest(current) != expected:
|
||||
differences.append(relative)
|
||||
if any(name != "runtime/main.c" for name in differences):
|
||||
raise RuntimeError(f"Cached numerical sources differ from current sources: {differences}")
|
||||
compiler, sundials, compiler_version = toolchain()
|
||||
if not sys.platform.startswith("linux"):
|
||||
raise RuntimeError("This test-only cleanup-scope profiler requires Linux/GCC")
|
||||
libraries = [sundials / "lib" / f"libsundials_{name}.a" for name in LIBRARIES]
|
||||
for name, expected in manifest["dependencyHashes"].items():
|
||||
path = sundials / ("include" if "/" in name else "lib") / name
|
||||
if digest(path) != expected:
|
||||
raise RuntimeError(f"SUNDIALS dependency changed: {name}")
|
||||
if compiler_version != manifest["compiler"]:
|
||||
raise RuntimeError("Use the cached model's compiler version for this comparison")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
native = output / "native"
|
||||
shutil.copytree(ROOT / "native", native, dirs_exist_ok=True)
|
||||
for name in ("model.c", "model.h", "manifest.json"):
|
||||
shutil.copy2(cache / name, output / name)
|
||||
instrument(native)
|
||||
command = [compiler, *manifest["compilerFlags"], "-I", str(output), "-I", str(native / "include"), "-I", str(sundials / "include"), str(output / "model.c"), *map(str, sorted(native.rglob("*.c"))), "-Wl,--start-group", *map(str, libraries), "-Wl,--end-group", "-lm", "-o", str(output / "profiled-model")]
|
||||
prepared = {"controlExecutable": str(cache / "model"), "profiledExecutable": str(output / "profiled-model"), "buildCommand": command, "runtimeArguments": runtime_arguments(args.request_stages), "cacheDir": str(cache), "cachedMainDifference": differences, "stateCount": len(manifest["stateKeys"]), "modelSha256": digest(output / "model.c"), "compiler": compiler_version, "warmups": args.warmups, "repeats": args.repeats, "scope": "Inclusive times overlap. Exclusive scope times partition integration including instrumentation overhead. No component or libc sub-cost inference.", "sourceHashes": {str(p.relative_to(output)): digest(p) for p in sorted(native.rglob("*")) if p.is_file()}}
|
||||
write_json(output / "prepared.json", prepared)
|
||||
return prepared
|
||||
|
||||
|
||||
def parity_payload(result: dict) -> dict:
|
||||
return {key: value for key, value in result.items() if key not in {"solveSeconds", "solveCpuSeconds"}}
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace, prepared: dict) -> None:
|
||||
output = args.output_dir.resolve()
|
||||
build = subprocess.run(prepared["buildCommand"], capture_output=True, text=True, timeout=180)
|
||||
(output / "build.log").write_text(build.stdout + build.stderr)
|
||||
if build.returncode:
|
||||
raise RuntimeError(f"Compilation failed: {output / 'build.log'}")
|
||||
baseline = None
|
||||
rows = []
|
||||
# Serial paired control/profile runs; warmups excluded from overhead figures.
|
||||
for index in range(-args.warmups, args.repeats):
|
||||
label = f"warmup-{index + args.warmups + 1}" if index < 0 else f"run-{index + 1}"
|
||||
for variant in ("control", "profiled"):
|
||||
run = output / variant / label
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
result_path, profile_path = run / "result.json", run / "profile.json"
|
||||
for stale in (result_path, profile_path, run / "cancel.request"):
|
||||
stale.unlink(missing_ok=True)
|
||||
command = [prepared[f"{variant}Executable"], *prepared["runtimeArguments"], "--output", str(result_path), "--result-index", str(run / "result-index.json"), "--cancel-file", str(run / "cancel.request")]
|
||||
environment = dict(os.environ)
|
||||
environment.pop("NATIVE_COMPUTE_PROFILE", None)
|
||||
# Disable independent sparse-stage profilers in a cached control.
|
||||
environment.pop("NATIVE_STAGE_PROFILE", None)
|
||||
if variant == "profiled":
|
||||
environment["NATIVE_COMPUTE_PROFILE"] = str(profile_path)
|
||||
started = time.perf_counter()
|
||||
process = subprocess.run(command, env=environment, capture_output=True, timeout=args.process_timeout)
|
||||
wall = time.perf_counter() - started
|
||||
(run / "stdout.log").write_bytes(process.stdout)
|
||||
(run / "stderr.log").write_bytes(process.stderr)
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"{variant}/{label} exit {process.returncode}; see stderr.log")
|
||||
result = json.loads(result_path.read_bytes())
|
||||
if result.get("success") is not True:
|
||||
raise RuntimeError(f"{variant}/{label} did not complete")
|
||||
comparable = parity_payload(result)
|
||||
if baseline is None:
|
||||
baseline = comparable
|
||||
if comparable != baseline:
|
||||
mismatches = [k for k in baseline.keys() | comparable.keys() if baseline.get(k) != comparable.get(k)]
|
||||
write_json(run / "parity-failure.json", mismatches)
|
||||
raise RuntimeError(f"Numerical/counter parity failed: {mismatches}")
|
||||
row = {"variant": variant, "run": label, "warmup": index < 0, "processWallSeconds": wall, "solveSeconds": result["solveSeconds"], "solveCpuSeconds": result["solveCpuSeconds"], "fullParity": True, "resultBytes": result_path.stat().st_size, "nfev": result["nfev"], "njev": result["njev"], "nlu": result["nlu"], "acceptedSteps": result["acceptedSteps"], "solverStarts": result["solverStarts"]}
|
||||
if variant == "profiled":
|
||||
profile = json.loads(profile_path.read_text())
|
||||
counters = profile["cvodeCounters"]
|
||||
checks = {"counterReadsSucceeded": profile["counterErrors"] == 0, "rhsCountMatches": counters["rhs"] + counters["linear_rhs"] == result["nfev"], "linearRhsEqualsJacobianCountTimesStates": counters["linear_rhs"] == result["njev"] * prepared["stateCount"], "rhsClockCountMatches": profile["scopes"]["integration"]["rhs"]["calls"] == result["nfev"]}
|
||||
row["profile"] = profile
|
||||
row["counterChecks"] = checks
|
||||
if not checks["counterReadsSucceeded"] or not checks["rhsClockCountMatches"] or (result["method"] == "BDF" and not checks["rhsCountMatches"]):
|
||||
write_json(run / "counter-failure.json", row)
|
||||
raise RuntimeError(f"Unexpected profiling counters: {checks}")
|
||||
rows.append(row)
|
||||
write_json(run / "run.json", row)
|
||||
print(f"{variant}/{label}: solve={row['solveSeconds']:.6f}s wall={wall:.6f}s parity=true", flush=True)
|
||||
medians = {variant: {key: statistics.median(row[key] for row in rows if row["variant"] == variant and not row["warmup"]) for key in ("processWallSeconds", "solveSeconds", "solveCpuSeconds")} for variant in ("control", "profiled")}
|
||||
overhead = {key: medians["profiled"][key] / medians["control"][key] - 1 for key in medians["control"]}
|
||||
write_json(output / "summary.json", {"prepared": prepared, "runs": rows, "medians": medians, "instrumentationOverheadFraction": overhead, "allFullParity": True, "interpretation": "CVODE linear_rhs counts finite-difference RHS calls independently of model nfev. Its multiplication by stateCount is checked, not assumed. RHS time includes all model work; no Jacobian-specific RHS time is inferred. cvode_step.exclusiveSeconds excludes nested RHS, polling and Dense ops; integration.exclusiveSeconds is outer setup/loop/cleanup work. Dense setup covers the original factorization operation; dense_solve covers the original triangular solve operation. Clock/bookkeeping costs remain in all measured totals. Production control may retain its pre-existing sparse main clocks; cachedMainDifference records this."})
|
||||
print(f"Summary: {output / 'summary.json'}", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--cache-dir", required=True, type=Path)
|
||||
parser.add_argument("--request-stages", type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--run", action="store_true", help="Build and run serial warmups/repeats; default only prepares")
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--process-timeout", type=float, default=360)
|
||||
args = parser.parse_args()
|
||||
if args.warmups < 0 or args.repeats < 1:
|
||||
parser.error("warmups must be nonnegative and repeats positive")
|
||||
prepared = prepare(args)
|
||||
print(f"Prepared: {args.output_dir.resolve() / 'prepared.json'}", flush=True)
|
||||
if args.run:
|
||||
execute(args, prepared)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Instrument isolated pipe solvers; these runs are diagnostics, never benchmarks.
|
||||
|
||||
Prepare: python tests/manual/profile_pipe_iterations.py --output-dir test/pipe-profile --prepare-only
|
||||
Run the prepared diagnostic programs: use the same command without --prepare-only.
|
||||
All mutations except this test helper stay below the ignored output directory.
|
||||
The replay contains EVERY resistance-law input from the guarded solver's RHS
|
||||
trajectory, including its scalar low-Re analytic branch. Cache hits, zero dp,
|
||||
and the separate PNL00R analytic law are counted but do not enter this replay.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.backends import simulation_config
|
||||
from app.simulation.native_codegen import build as builder
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
|
||||
VARIANTS = ('guarded-newton', 'previous-newton', 'fixed-point')
|
||||
FIELDS = '''cache_requests cache_hits cache_misses flow_calls zero_pressure_calls
|
||||
pnl00r_analytic_calls resistance_calls scalar_analytic_calls iterative_calls
|
||||
iterations_total iterations_max reached_last_iteration exhausted_limit
|
||||
algorithm_converged failed_before_iteration finite_returns nonfinite_returns
|
||||
bisections residual_pass residual_fail residual_nonfinite
|
||||
residual_fail_after_algorithm_converged residual_pass_after_limit
|
||||
invalid_inputs upper_bracket_evaluations upper_bracket_exhausted
|
||||
bisection_after_poor_progress bisection_invalid_or_outside float_stagnation
|
||||
'''.split()
|
||||
|
||||
PROFILE_PREFIX = r'''
|
||||
/* Test-only instrumentation, injected in an isolated source tree. */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#define PROFILE_VARIANT "@VARIANT@"
|
||||
#define PROFILE_FIXED @FIXED@
|
||||
#define PROFILE_LIMIT(kind) @LIMIT@
|
||||
#define PROFILE_FIELDS(X) @FIELDS@
|
||||
typedef struct {
|
||||
#define PROFILE_DECLARE(name) unsigned long long name;
|
||||
PROFILE_FIELDS(PROFILE_DECLARE)
|
||||
#undef PROFILE_DECLARE
|
||||
unsigned long long histogram[129];
|
||||
double maximum_relative_residual;
|
||||
} PipeProfile;
|
||||
static PipeProfile profile_stats[2][4];
|
||||
static unsigned long long profile_rhs_calls,profile_capture_records;
|
||||
static int profile_in_rhs,profile_kind,profile_last_exhausted;
|
||||
static FILE *profile_capture;
|
||||
static PipeProfile *profile_bucket(int kind) {
|
||||
if(kind<0 || kind>3){fprintf(stderr,"Unexpected pipe kind %d\n",kind);exit(71);}
|
||||
return &profile_stats[profile_in_rhs?0:1][kind];
|
||||
}
|
||||
void pipe_profile_rhs_enter(void){profile_in_rhs=1;profile_rhs_calls++;}
|
||||
void pipe_profile_rhs_leave(void){profile_in_rhs=0;}
|
||||
static void profile_add(PipeProfile *total,const PipeProfile *value) {
|
||||
#define PROFILE_ADD(name) total->name+=value->name;
|
||||
PROFILE_FIELDS(PROFILE_ADD)
|
||||
#undef PROFILE_ADD
|
||||
if(value->iterations_max>total->iterations_max)total->iterations_max=value->iterations_max;
|
||||
for(int i=0;i<129;i++)total->histogram[i]+=value->histogram[i];
|
||||
if(value->maximum_relative_residual>total->maximum_relative_residual)
|
||||
total->maximum_relative_residual=value->maximum_relative_residual;
|
||||
}
|
||||
static void profile_write_bucket(FILE *f,const PipeProfile *value) {
|
||||
fprintf(f,"{");
|
||||
#define PROFILE_WRITE(name) fprintf(f,"\"" #name "\":%llu,",value->name);
|
||||
PROFILE_FIELDS(PROFILE_WRITE)
|
||||
#undef PROFILE_WRITE
|
||||
fprintf(f,"\"maximum_relative_residual\":%.17g,\"iteration_histogram\":{",value->maximum_relative_residual);
|
||||
int comma=0;
|
||||
for(int i=0;i<129;i++)if(value->histogram[i]) {
|
||||
fprintf(f,"%s\"%d\":%llu",comma?",":"",i,value->histogram[i]);comma=1;
|
||||
}
|
||||
fprintf(f,"}}");
|
||||
}
|
||||
static void profile_dump(void) {
|
||||
const char *path=getenv("PIPE_PROFILE_JSON");
|
||||
if(profile_capture){if(fclose(profile_capture))exit(73);profile_capture=NULL;}
|
||||
if(!path)return;
|
||||
FILE *f=fopen(path,"wb");if(!f){perror(path);exit(73);}
|
||||
fprintf(f,"{\"variant\":\"%s\",\"rhs_calls\":%llu,\"capture_records\":%llu,",PROFILE_VARIANT,profile_rhs_calls,profile_capture_records);
|
||||
for(int scope=0;scope<2;scope++) {
|
||||
PipeProfile total={0};
|
||||
for(int kind=0;kind<4;kind++)profile_add(&total,&profile_stats[scope][kind]);
|
||||
/* iterations_max is a maximum, unlike the additive counters. */
|
||||
total.iterations_max=0;
|
||||
for(int kind=0;kind<4;kind++)if(profile_stats[scope][kind].iterations_max>total.iterations_max)
|
||||
total.iterations_max=profile_stats[scope][kind].iterations_max;
|
||||
fprintf(f,"%s\"%s\":{\"total\":",scope?",":"",scope?"non_rhs":"rhs");
|
||||
profile_write_bucket(f,&total);fprintf(f,",\"by_kind\":{");
|
||||
for(int kind=0;kind<4;kind++) {
|
||||
fprintf(f,"%s\"%d\":",kind?",":"",kind);profile_write_bucket(f,&profile_stats[scope][kind]);
|
||||
}
|
||||
fprintf(f,"}}");
|
||||
}
|
||||
fprintf(f,"}\n");if(fclose(f))exit(73);
|
||||
}
|
||||
void pipe_profile_install(void) {
|
||||
const char *path=getenv("PIPE_PROFILE_CAPTURE");
|
||||
if(path){profile_capture=fopen(path,"wb");if(!profile_capture){perror(path);exit(73);}}
|
||||
if(atexit(profile_dump)){fprintf(stderr,"Cannot register profile writer\n");exit(73);}
|
||||
}
|
||||
static void profile_save_input(double base,double d,double length,double rr,double den,int kind) {
|
||||
if(profile_in_rhs && profile_capture) {
|
||||
/* Six IEEE doubles, native endian; no sampling or deduplication. */
|
||||
double input[]={base,d,length,rr,den,(double)kind};
|
||||
if(fwrite(input,sizeof(input),1,profile_capture)!=1){perror("capture");exit(73);}
|
||||
profile_capture_records++;
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
PROFILE_SOLVE = r'''
|
||||
/* Independently factored Darcy law: no production slope or solver status is
|
||||
consulted. Long double reduces rounding noise in the returned-q residual. */
|
||||
static long double profile_reference_friction(long double re,long double rr) {
|
||||
if(!(re>0))return NAN;
|
||||
long double laminar=64/re;
|
||||
if(re<=89.96829989L)return laminar;
|
||||
long double smooth=powl(-1.8L*log10l(6.9L/re),-2),turbulent=smooth;
|
||||
if(rr>0) {
|
||||
long double fully_rough=powl(-2*log10l(rr/3.7L),-2);
|
||||
long double weight=1/(1+powl(180/(re*rr),2));
|
||||
turbulent=(1-weight)*smooth+weight*fully_rough;
|
||||
}
|
||||
long double blend=powl((re-89.96829989L)/2741.96700831L,8.37293695L);
|
||||
return (laminar+blend*turbulent)/(1+blend);
|
||||
}
|
||||
static void profile_returned_residual(PipeProfile *s,double q,double den,double K,double rr,
|
||||
int converged,int exhausted) {
|
||||
if(!isfinite(q)){s->nonfinite_returns++;s->residual_nonfinite++;return;}
|
||||
s->finite_returns++;
|
||||
long double re=fabsl((long double)q/(den/4)),residual;
|
||||
if(K==0 && q==0)residual=0;
|
||||
else residual=fabsl(re*re*profile_reference_friction(re,rr)/K-1);
|
||||
if(!isfinite(residual)){s->residual_nonfinite++;return;}
|
||||
if(residual>s->maximum_relative_residual)s->maximum_relative_residual=(double)residual;
|
||||
if(residual<=1e-9L){s->residual_pass++;if(exhausted)s->residual_pass_after_limit++;}
|
||||
else {s->residual_fail++;if(converged)s->residual_fail_after_algorithm_converged++;}
|
||||
}
|
||||
static double profile_solve(double base,double d,double length,double rr,double den,int kind) {
|
||||
PipeProfile *s=profile_bucket(kind);
|
||||
double K=pow(4*base/den,2)*d/length;
|
||||
profile_save_input(base,d,length,rr,den,kind);
|
||||
s->resistance_calls++;
|
||||
if(!(K>=0 && rr>=0 && den/4>0) || !isfinite(K) || !isfinite(rr) || !isfinite(den/4))s->invalid_inputs++;
|
||||
int iterations=0,converged=0,bisections=0,exhausted=0;
|
||||
double q;
|
||||
profile_kind=kind;profile_last_exhausted=0;
|
||||
#if PROFILE_FIXED
|
||||
double rough_limit=pipe_rough_limit(rr);
|
||||
q=sqrt(d/(length*.02))*base;
|
||||
for(int i=0;i<PROFILE_LIMIT(kind);i++) {
|
||||
iterations=i+1;
|
||||
double next=sqrt(d/(length*pipe_friction_prepared(4*fabs(q)/den,rr,rough_limit)))*base;
|
||||
if(fabs(next-q)<=fmax(1e-12,fabs(q)*1e-9)){q=next;converged=1;break;}
|
||||
q=.5*(q+next);
|
||||
}
|
||||
exhausted=!converged;
|
||||
#else
|
||||
NativePipeSolve status;
|
||||
q=native_pipe_resistance(K,rr,den/4,&status)*den/4;
|
||||
iterations=status.iterations;converged=status.converged;bisections=status.bisections;
|
||||
exhausted=profile_last_exhausted;
|
||||
#endif
|
||||
if(iterations<0 || iterations>128){fprintf(stderr,"Unexpected iteration count\n");exit(71);}
|
||||
s->histogram[iterations]++;s->iterations_total+=(unsigned)iterations;s->bisections+=(unsigned)bisections;
|
||||
if((unsigned)iterations>s->iterations_max)s->iterations_max=(unsigned)iterations;
|
||||
if(iterations)s->iterative_calls++;
|
||||
else if(converged)s->scalar_analytic_calls++;
|
||||
else s->failed_before_iteration++;
|
||||
if(iterations==PROFILE_LIMIT(kind))s->reached_last_iteration++;
|
||||
if(exhausted)s->exhausted_limit++;
|
||||
if(converged)s->algorithm_converged++;
|
||||
profile_returned_residual(s,q,den,K,rr,converged,exhausted);
|
||||
return q;
|
||||
}
|
||||
'''
|
||||
|
||||
REPLAY_MAIN = r'''
|
||||
#include "native/components/kernels.c"
|
||||
int main(int argc,char **argv) {
|
||||
if(argc!=2)return 64;
|
||||
pipe_profile_install();profile_in_rhs=1;
|
||||
FILE *f=fopen(argv[1],"rb");if(!f){perror(argv[1]);return 73;}
|
||||
double input[6];size_t count;
|
||||
while((count=fread(input,1,sizeof(input),f))==sizeof(input)) {
|
||||
profile_solve(input[0],input[1],input[2],input[3],input[4],(int)input[5]);
|
||||
}
|
||||
int failed=count || ferror(f);fclose(f);return failed?74:0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def replace_once(source: str, old: str, new: str) -> str:
|
||||
if source.count(old) != 1:
|
||||
raise ValueError(f'Expected exactly one audited source fragment: {old[:100]!r}')
|
||||
return source.replace(old, new, 1)
|
||||
|
||||
|
||||
def instrument(source: str, variant: str) -> str:
|
||||
fixed = variant == 'fixed-point'
|
||||
limit = '(kind==0?64:16)' if fixed else '80' if variant == 'previous-newton' else '128'
|
||||
prefix = PROFILE_PREFIX.replace('@VARIANT@', variant).replace('@FIXED@', str(int(fixed)))
|
||||
prefix = prefix.replace('@LIMIT@', limit).replace('@FIELDS@', ' '.join(f'X({name})' for name in FIELDS))
|
||||
source = replace_once(source, '#include <stddef.h>', '#include <stddef.h>\n' + prefix)
|
||||
# Exhaustion is marked at the actual loop fall-through, not inferred from
|
||||
# visiting the last allowed iteration (which can still converge).
|
||||
start = source.index('double native_pipe_resistance(')
|
||||
end = source.index('double native_pipe_flow(', start)
|
||||
resistance = source[start:end]
|
||||
ending = ' return NAN;\n}\n'
|
||||
if not resistance.endswith(ending):
|
||||
raise ValueError('Unexpected resistance function ending')
|
||||
resistance = resistance[:-len(ending)] + ' profile_last_exhausted=1;return NAN;\n}\n'
|
||||
if variant == 'guarded-newton':
|
||||
resistance = replace_once(resistance, ' double value=hi*hi*pipe_friction_prepared(hi,rr,rough);',
|
||||
' profile_bucket(profile_kind)->upper_bracket_evaluations++;\n double value=hi*hi*pipe_friction_prepared(hi,rr,rough);')
|
||||
resistance = replace_once(resistance, ' if(!bracketed)return NAN;',
|
||||
' if(!bracketed){profile_bucket(profile_kind)->upper_bracket_exhausted++;return NAN;}')
|
||||
resistance = replace_once(resistance, ' if(bisect) {',
|
||||
''' if(bisect) {
|
||||
if(previous_newton && fabs(F)>.5*previous_residual)profile_bucket(profile_kind)->bisection_after_poor_progress++;
|
||||
if(!(slope>0) || !isfinite(slope) || !isfinite(next) || next<=lo || next>=hi)
|
||||
profile_bucket(profile_kind)->bisection_invalid_or_outside++;''')
|
||||
resistance = replace_once(resistance, ' if(next<=lo || next>=hi) {',
|
||||
' if(next<=lo || next>=hi) {\n profile_bucket(profile_kind)->float_stagnation++;')
|
||||
elif variant == 'previous-newton':
|
||||
# Count each evaluation of the original loop condition without changing
|
||||
# its short-circuit behaviour or the original upper endpoint arithmetic.
|
||||
resistance = replace_once(resistance, 'i<128 && hi*hi*pipe_friction_prepared(hi,rr,rough)<K',
|
||||
'i<128 && (profile_bucket(profile_kind)->upper_bracket_evaluations++,hi*hi*pipe_friction_prepared(hi,rr,rough)<K)')
|
||||
resistance = replace_once(resistance, 'status->bisections++;',
|
||||
'status->bisections++;profile_bucket(profile_kind)->bisection_invalid_or_outside++;')
|
||||
source = source[:start] + resistance + PROFILE_SOLVE + source[end:]
|
||||
source = replace_once(source, ' if(fabs(p1-p2)<=1e-8) return 0;',
|
||||
''' PipeProfile *profile=profile_bucket(kind);profile->flow_calls++;
|
||||
if(fabs(p1-p2)<=1e-8){profile->zero_pressure_calls++;return 0;}''')
|
||||
source = replace_once(source, ' if(4*lam/den<=1000) return sign*lam;',
|
||||
' if(4*lam/den<=1000){profile->pnl00r_analytic_calls++;return sign*lam;}')
|
||||
source = replace_once(source,
|
||||
' double base=area*p*cm/sqrt(T),K=pow(4*base/den,2)*d/length;\n return sign*native_pipe_resistance(K,rr,den/4,NULL)*den/4;',
|
||||
' double base=area*p*cm/sqrt(T);\n return sign*profile_solve(base,d,length,rr,den,kind);')
|
||||
source = replace_once(source, ' if(cache->valid && cache->p1==p1 && cache->p2==p2 && cache->T==T &&',
|
||||
' PipeProfile *profile=profile_bucket(kind);profile->cache_requests++;\n if(cache->valid && cache->p1==p1 && cache->p2==p2 && cache->T==T &&')
|
||||
source = replace_once(source, ' return cache->flow;\n double result=properties?',
|
||||
' {profile->cache_hits++;return cache->flow;}\n profile->cache_misses++;\n double result=properties?')
|
||||
return source
|
||||
|
||||
|
||||
def git(*arguments: str) -> str:
|
||||
return subprocess.check_output(['git', *arguments], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def prepare(args, out: Path) -> dict:
|
||||
if (out / 'prepared.json').exists():
|
||||
metadata = json.loads((out / 'prepared.json').read_text())
|
||||
if metadata['input_sha256'] != sha256(args.input.read_bytes()).hexdigest():
|
||||
raise ValueError('Prepared model no longer matches the input file')
|
||||
if metadata['script_sha256'] != sha256(Path(__file__).read_bytes()).hexdigest():
|
||||
raise ValueError('Diagnostic helper changed; choose a fresh output directory')
|
||||
return metadata
|
||||
previous = git('rev-parse', args.previous_ref)
|
||||
current = git('rev-parse', args.current_ref)
|
||||
xml, doc = load_input(args.input)
|
||||
config = simulation_config(doc.simulation)
|
||||
if config.rtol != 1e-8 or config.t_start != 0 or config.t_stop != 10:
|
||||
raise ValueError('This experiment requires API default rtol=1e-8 and 0–10 s model settings')
|
||||
program = compile_native_program(compile_system_xml_network(doc))
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'input.xml').write_bytes(xml)
|
||||
(out / 'input.json').write_bytes(args.input.read_bytes())
|
||||
paths = git('ls-tree', '-r', '--name-only', current, 'native').splitlines()
|
||||
metadata = dict(input=str(args.input.resolve()), input_sha256=sha256(args.input.read_bytes()).hexdigest(),
|
||||
xml_sha256=sha256(xml).hexdigest(), script_sha256=sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
previous_revision=previous, current_revision=current, settings=vars(config),
|
||||
sample_step=doc.simulation.sample_step, variants={},
|
||||
measurement_note='Instrumented times are diagnostic overhead and MUST NOT be used as production benchmark results.',
|
||||
counter_scope='rhs counts native_rhs/model_eval calls including rejected trials and Jacobian differences; non_rhs includes initialization/output/probe evaluations.',
|
||||
replay_scope='All guarded RHS resistance calls, including scalar analytic low-Re cases; no sampling or deduplication. Cache hits, zero pressure difference and direct PNL00R analytic calls are excluded and counted separately.',
|
||||
capture_format='Native-endian IEEE-754 binary64 records: base, diameter, length, relative roughness, den=pi*d*mu, kind (six doubles, 48 bytes). Replay on the same host.',
|
||||
residual_test='At actual returned q, independently factored long-double f(Re) evaluates abs(Re^2*f(Re)/K-1)<=1e-9. Separate from each algorithm stopping rule.')
|
||||
original_native = builder.NATIVE
|
||||
try:
|
||||
for variant in VARIANTS:
|
||||
directory = out / variant
|
||||
revision = current if variant == 'guarded-newton' else previous
|
||||
for name in paths:
|
||||
target = directory / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(subprocess.check_output(['git', 'show', f'{revision}:{name}'], cwd=ROOT))
|
||||
kernel = directory / 'native/components/kernels.c'
|
||||
original_hash = sha256(kernel.read_bytes()).hexdigest()
|
||||
kernel.write_text(instrument(kernel.read_text(), variant))
|
||||
common = directory / 'native/runtime/common.c'
|
||||
common.write_text(replace_once(common.read_text(), ' return model_eval(t,y,dy,w);',
|
||||
''' extern void pipe_profile_rhs_enter(void),pipe_profile_rhs_leave(void);
|
||||
pipe_profile_rhs_enter();int ok=model_eval(t,y,dy,w);pipe_profile_rhs_leave();return ok;'''))
|
||||
main = directory / 'native/runtime/main.c'
|
||||
main.write_text(replace_once(main.read_text(), 'int main(int argc, char **argv) {',
|
||||
'int main(int argc, char **argv) {\n extern void pipe_profile_install(void);pipe_profile_install();'))
|
||||
builder.NATIVE = directory / 'native'
|
||||
build = builder.build_native(program, cache_dir=out / 'cache')
|
||||
replay_source = directory / 'replay.c'
|
||||
replay_source.write_text(REPLAY_MAIN)
|
||||
replay = directory / ('replay.exe' if os.name == 'nt' else 'replay')
|
||||
compiler, _, _ = builder.toolchain()
|
||||
command = [compiler, '-std=c11', '-O3', '-Wall', '-Wextra', '-Werror', '-ffp-contract=off',
|
||||
'-fno-fast-math', '-I', str(directory / 'native/include'), str(replay_source), '-lm', '-o', str(replay)]
|
||||
compiled = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
(directory / 'replay-build.log').write_text(compiled.stdout + compiled.stderr)
|
||||
if compiled.returncode:
|
||||
raise RuntimeError(f'Replay compilation failed: {compiled.stderr}')
|
||||
metadata['variants'][variant] = dict(executable=str(build.executable), replay=str(replay),
|
||||
build_key=build.manifest['buildKey'], original_kernel_sha256=original_hash,
|
||||
instrumented_kernel_sha256=sha256(kernel.read_bytes()).hexdigest())
|
||||
print(f'Prepared {variant}', flush=True)
|
||||
finally:
|
||||
builder.NATIVE = original_native
|
||||
(out / 'prepared.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n')
|
||||
return metadata
|
||||
|
||||
|
||||
def load_stats(path: Path) -> dict:
|
||||
stats = json.loads(path.read_text())
|
||||
for scope in ('rhs', 'non_rhs'):
|
||||
for row in [stats[scope]['total'], *stats[scope]['by_kind'].values()]:
|
||||
assert row['cache_requests'] == row['cache_hits'] + row['cache_misses']
|
||||
assert row['flow_calls'] == row['zero_pressure_calls'] + row['pnl00r_analytic_calls'] + row['resistance_calls'] or not row['flow_calls']
|
||||
assert row['resistance_calls'] == row['iterative_calls'] + row['scalar_analytic_calls'] + row['failed_before_iteration']
|
||||
assert row['resistance_calls'] == sum(row['iteration_histogram'].values())
|
||||
assert row['iterations_total'] == sum(int(k) * v for k, v in row['iteration_histogram'].items())
|
||||
assert row['resistance_calls'] == row['finite_returns'] + row['nonfinite_returns']
|
||||
assert row['resistance_calls'] == row['residual_pass'] + row['residual_fail'] + row['residual_nonfinite']
|
||||
assert row['exhausted_limit'] <= row['reached_last_iteration']
|
||||
return stats
|
||||
|
||||
|
||||
def run(metadata: dict, out: Path, timeout: float):
|
||||
summary_path = out / 'summary.json'
|
||||
if summary_path.exists():
|
||||
raise ValueError('Diagnostic results already exist; choose a fresh output directory')
|
||||
settings = metadata['settings']
|
||||
capture = out / 'guarded-rhs-resistance-inputs.bin'
|
||||
rows = {}
|
||||
for variant in VARIANTS:
|
||||
directory = out / variant / 'trajectory'
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
result_path = directory / 'result.json'
|
||||
stats_path = directory / 'profile.json'
|
||||
env = os.environ.copy()
|
||||
env.pop('PIPE_PROFILE_CAPTURE', None)
|
||||
env['PIPE_PROFILE_JSON'] = str(stats_path)
|
||||
if variant == 'guarded-newton':
|
||||
env['PIPE_PROFILE_CAPTURE'] = str(capture)
|
||||
executable = metadata['variants'][variant]['executable']
|
||||
command = [executable, '--method', settings['method'], '--start', str(settings['t_start']),
|
||||
'--stop', str(settings['t_stop']), '--sample-step', str(metadata['sample_step']),
|
||||
'--max-step', str(settings['max_step']), '--rtol', str(settings['rtol']),
|
||||
'--timeout', str(timeout), '--output', str(result_path)]
|
||||
print(f'Starting diagnostic trajectory: {variant}', flush=True)
|
||||
with (directory / 'worker.log').open('w') as log:
|
||||
try:
|
||||
process = subprocess.run(command, cwd=Path(executable).parent, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=log, timeout=timeout+15)
|
||||
exit_code = process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
exit_code = 'external-timeout'
|
||||
data = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
# Deliberately omit measured times from the cross-variant summary.
|
||||
trajectory = {key:data.get(key) for key in ('success', 'status', 'message', 'simulatedUntil',
|
||||
'nfev', 'acceptedSteps', 'rejectedSteps', 'njev', 'nlu', 'stateTransitions')}
|
||||
trajectory['exit_code'] = exit_code
|
||||
rows[variant] = dict(trajectory=trajectory, profile=load_stats(stats_path) if stats_path.exists() else None)
|
||||
print(json.dumps(dict(variant=variant, **trajectory), ensure_ascii=False), flush=True)
|
||||
if capture.stat().st_size % 48:
|
||||
raise ValueError('Truncated replay capture')
|
||||
count = capture.stat().st_size // 48
|
||||
guarded = rows['guarded-newton']['profile']
|
||||
if guarded is None or count != guarded['rhs']['total']['resistance_calls'] or count != guarded['capture_records']:
|
||||
raise ValueError('Capture does not contain every guarded RHS resistance call')
|
||||
for variant in VARIANTS:
|
||||
directory = out / variant / 'replay-results'
|
||||
directory.mkdir(exist_ok=False)
|
||||
stats_path = directory / 'profile.json'
|
||||
env = os.environ.copy()
|
||||
env.pop('PIPE_PROFILE_CAPTURE', None)
|
||||
env['PIPE_PROFILE_JSON'] = str(stats_path)
|
||||
print(f'Replaying all {count} common inputs: {variant}', flush=True)
|
||||
with (directory / 'worker.log').open('w') as log:
|
||||
subprocess.run([metadata['variants'][variant]['replay'], str(capture)], env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=log, timeout=300, check=True)
|
||||
replay = load_stats(stats_path)
|
||||
if replay['rhs']['total']['resistance_calls'] != count:
|
||||
raise ValueError('Replay count mismatch')
|
||||
rows[variant]['replay'] = replay
|
||||
# Identical input replay must reproduce every resistance-solve counter for
|
||||
# the guarded solver, excluding cache/flow bookkeeping performed upstream.
|
||||
exempt = {'cache_requests', 'cache_hits', 'cache_misses', 'flow_calls', 'zero_pressure_calls', 'pnl00r_analytic_calls'}
|
||||
for kind in ('total', '0', '1', '2', '3'):
|
||||
actual = guarded['rhs']['total'] if kind == 'total' else guarded['rhs']['by_kind'][kind]
|
||||
replay = rows['guarded-newton']['replay']['rhs']['total'] if kind == 'total' else rows['guarded-newton']['replay']['rhs']['by_kind'][kind]
|
||||
assert {k:v for k,v in actual.items() if k not in exempt} == {k:v for k,v in replay.items() if k not in exempt}
|
||||
summary = dict(metadata=metadata, capture=dict(path=str(capture), records=count, bytes=capture.stat().st_size,
|
||||
sha256=sha256(capture.read_bytes()).hexdigest(), sampling='none'), variants=rows)
|
||||
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + '\n')
|
||||
print(f'Diagnostic profile complete: {summary_path}', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--input', type=Path, default=ROOT / 'tests/data/test-mql-8-corrected.json')
|
||||
parser.add_argument('--output-dir', type=Path, required=True)
|
||||
parser.add_argument('--previous-ref', default='5d5a2e1')
|
||||
parser.add_argument('--current-ref', default='808c484')
|
||||
parser.add_argument('--timeout', type=float, default=120)
|
||||
parser.add_argument('--prepare-only', action='store_true')
|
||||
args = parser.parse_args()
|
||||
out = args.output_dir.resolve()
|
||||
# Keep diagnostic native copies out of production sources and tracked data.
|
||||
if not out.is_relative_to(ROOT / 'test'):
|
||||
parser.error('--output-dir must be below the ignored repository test/ directory')
|
||||
metadata = prepare(args, out)
|
||||
if not args.prepare_only:
|
||||
run(metadata, out, args.timeout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Summarize the four complete C-result-encoding browser groups.
|
||||
|
||||
.venv/bin/python tests/manual/summarize_native_encoding.py \
|
||||
--root test/c-result-encoding-20260911
|
||||
|
||||
Reads small timing metadata only, never result arrays or CSV contents. Every
|
||||
browser group must contain one warmup and three measured successful runs.
|
||||
Missing/inconsistent evidence exits 2 and writes complete:false plus an empty
|
||||
CSV, so an earlier successful summary cannot masquerade as current evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GROUPS = {
|
||||
"baseline": ("control", None),
|
||||
"baseline-profiled": ("profiled", "baseline-source/profiled-backend/requests"),
|
||||
"optimized": ("control", None),
|
||||
"optimized-profiled": ("profiled", "backend-optimized-profiled/requests"),
|
||||
}
|
||||
GOALS = {
|
||||
"ready": "clickToReadyDomMs",
|
||||
"saved_observed": "clickToIndexedDbObservedMs",
|
||||
"csv_download_saved": "csvClickToDownloadSavedMs",
|
||||
}
|
||||
COUNTERS = ("nfev", "acceptedSteps", "rejectedSteps", "stateTransitions", "solverStarts", "njev", "nlu")
|
||||
NATIVE_IDENTITY = ("backend", "method", "solver", "sundialsVersion", "simulatedUntil", "maxAcceptedStep", *COUNTERS)
|
||||
C_WALL = ("argumentPreparationSeconds", "initializationSeconds", "integrationSeconds",
|
||||
"finalSampleAndStatusSeconds", "projectionSeconds", "jsonWriteSeconds", "mainTotalSeconds")
|
||||
CSV_FIELDS = ("group", "phase", "run", "simulationId", "domain", "metric", "statistic", "value", "unit",
|
||||
"parent", "denominatorValue", "percentOfParent", "baselineValue", "optimizedValue",
|
||||
"count", "missingCount", "inclusion", "source")
|
||||
DEFINITIONS = {
|
||||
"scope": "One C-result-encoding experiment. Baseline/optimized control groups alone provide end-to-end comparisons; profiled C-write comparisons are separate diagnostics.",
|
||||
"statistics": "Run values precede median/min/max. Stage/parent percentages use each run's own denominator before aggregation. Before/after changes use the ratio of independently collected group medians; medians and overlapping stages must not be added.",
|
||||
"comparison": "Groups were collected separately, not as alternating paired trials. Duration reduction = (baseline median - optimized median) / baseline median; speedup = baseline median / optimized median. Run ordinals are not matched pairs. Ordering, scheduling and thermal variability remain possible.",
|
||||
"ready": "Click to DOM-observed successful completion and an enabled Run button, not GPU completion.",
|
||||
"saved": "End-to-end comparisons use pointer polling observation in BOTH control groups; includes polling and scheduling latency. Exact instrumented pointer publication remains a separate profiled metric.",
|
||||
"csv": "Click through Playwright download notification and saveAs completion; includes automation and filesystem work.",
|
||||
"backend": "Backend spans are inclusive wall intervals; children are included in their parents. ASGI send awaits are not pure network time. Response serialization includes metadata encoding and raw numeric-fragment joining.",
|
||||
"cWrite": "C output write includes numeric encoding, stdio writes, close and index writing. CPU and wall are distinct observations; their difference is not an isolated disk-I/O measurement.",
|
||||
"cSolve": "Integration includes CVODE setup, RHS/Jacobian/linear work, events and sampling. Counts are not CPU-time shares. Projection is outside integration and inside C main.",
|
||||
"process": "Native reported processWallSeconds includes Python result reading after child exit; observed process lifetime spans include spawn and exit-observation latency.",
|
||||
"overlap": "Browser reads overlap backend work. Parse/decode lie inside reception; persistence and rendering overlap. Per-stage percentages are inclusive and must not be added.",
|
||||
"warmup": "Warmup rows are retained separately. Formal browser runs require build-cache hits. CacheHit, not the warmup label, identifies cold compilation.",
|
||||
"replay": "Microbenchmark replays preloaded contiguous binary64 values, excluding model projection and production strided access. Its medians are separate and never substituted for end-to-end results.",
|
||||
"validation": "Timing metadata validates group completeness, input/assets, counters, sample/variable counts and recorded success/restore flags. It does not independently prove numerical bitwise parity; use the separate full-result comparison artifact.",
|
||||
}
|
||||
|
||||
|
||||
def numeric(value: Any) -> bool:
|
||||
return type(value) in (int, float) and math.isfinite(value)
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def stats(values: list[Any]) -> dict:
|
||||
present = [v for v in values if numeric(v)]
|
||||
return {"count": len(present), "missingCount": len(values) - len(present), "values": values,
|
||||
"median": median(present) if present else None,
|
||||
"min": min(present) if present else None, "max": max(present) if present else None}
|
||||
|
||||
|
||||
def percent(value: Any, denominator: Any) -> float | None:
|
||||
return value / denominator * 100 if numeric(value) and numeric(denominator) and denominator > 0 else None
|
||||
|
||||
|
||||
def finite_field(data: dict, name: str, context: str, *, positive: bool = False) -> float:
|
||||
value = data.get(name)
|
||||
require(numeric(value) and (value > 0 if positive else value >= 0), f"{context}: missing/invalid {name}")
|
||||
return value
|
||||
|
||||
|
||||
class Summarizer:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root.resolve()
|
||||
self.sources: dict[str, dict] = {}
|
||||
self.observations: list[dict] = []
|
||||
self.simulation_ids: set[str] = set()
|
||||
|
||||
def read(self, relative: str) -> dict:
|
||||
path = (self.root / relative).resolve()
|
||||
require(path.is_relative_to(self.root), f"Metadata path leaves experiment root: {relative}")
|
||||
require(path.is_file(), f"Incomplete experiment: missing {relative}")
|
||||
require(path.stat().st_size <= 4 * 1024 * 1024, f"Refusing large non-metadata input: {relative}")
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw, parse_constant=lambda token: (_ for _ in ()).throw(ValueError(f"Invalid JSON number: {token}")))
|
||||
require(isinstance(value, dict), f"Expected metadata object: {relative}")
|
||||
self.sources[relative] = {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
||||
return value
|
||||
|
||||
def observe(self, run: dict, domain: str, metric: str, value: Any, unit: str = "ms", *,
|
||||
parent: str = "", denominator: Any = None, inclusion: str = "inclusive/overlapping; not additive",
|
||||
source: str = "", baseline: Any = None, optimized: Any = None) -> None:
|
||||
require(value is None or numeric(value), f"Invalid observation {domain}.{metric}: {value!r}")
|
||||
self.observations.append({k: run[k] for k in ("group", "phase", "run", "simulationId")} | {
|
||||
"domain": domain, "metric": metric, "value": value, "unit": unit, "parent": parent,
|
||||
"denominatorValue": denominator, "percentOfParent": percent(value, denominator),
|
||||
"baselineValue": baseline, "optimizedValue": optimized, "inclusion": inclusion, "source": source})
|
||||
|
||||
def backend(self, run: dict, relative: str) -> dict:
|
||||
data = self.read(relative)
|
||||
context = f"{run['group']}/{run['run']} backend"
|
||||
require(data.get("id") == run["simulationId"], f"{context}: simulationId mismatch")
|
||||
require(data.get("httpStatus") == 200, f"{context}: HTTP did not succeed")
|
||||
native = run["native"]
|
||||
for key in (*NATIVE_IDENTITY, "solveSeconds", "solveCpuSeconds", "buildKey", "cacheHit"):
|
||||
require(data.get("native", {}).get(key) == native.get(key), f"{context}: browser/backend mismatch for {key}")
|
||||
require(data.get("sampleCount") == run["sampleCount"], f"{context}: backend sampleCount mismatch")
|
||||
http = finite_field(data, "httpTotalSeconds", context, positive=True) * 1000
|
||||
self.observe(run, "backend", "httpTotalMs", http, source=relative, inclusion=DEFINITIONS["backend"])
|
||||
spans = data.get("spans", [])
|
||||
require(bool(spans), f"{context}: missing backend spans")
|
||||
totals: dict[str, float] = {}
|
||||
for span in spans:
|
||||
start = finite_field(span, "startMs", context)
|
||||
end = finite_field(span, "endMs", context)
|
||||
require(start <= end <= http + 1e-5, f"{context}: span outside HTTP interval: {span['name']}")
|
||||
totals[span["name"]] = totals.get(span["name"], 0) + end - start
|
||||
for name in ("native_indexed_result_read", "native_process_lifetime_observed", "response_result_json_serialization"):
|
||||
require(name in totals, f"{context}: missing {name}")
|
||||
for name, value in totals.items():
|
||||
self.observe(run, "backend_span", name, value, parent="httpTotalMs", denominator=http,
|
||||
inclusion=DEFINITIONS["backend"], source=relative)
|
||||
c = data.get("nativeStages", {})
|
||||
main_ms = finite_field(c, "mainTotalSeconds", context, positive=True) * 1000
|
||||
for name in C_WALL:
|
||||
self.observe(run, "c_wall", name.removesuffix("Seconds") + "Ms", finite_field(c, name, context) * 1000,
|
||||
parent="cMainMs" if name != "mainTotalSeconds" else "",
|
||||
denominator=main_ms if name != "mainTotalSeconds" else None,
|
||||
inclusion=DEFINITIONS["cWrite"] if name == "jsonWriteSeconds" else DEFINITIONS["cSolve"], source=relative)
|
||||
for name in ("projectionCpuSeconds", "jsonWriteCpuSeconds"):
|
||||
self.observe(run, "c_cpu", name.removesuffix("Seconds") + "Ms", finite_field(c, name, context) * 1000,
|
||||
inclusion="CPU duration; separate from wall intervals", source=relative)
|
||||
self.observe(run, "backend", "responseSendAwaitMs", finite_field(data, "responseSendAwaitSeconds", context) * 1000,
|
||||
parent="httpTotalMs", denominator=http, inclusion=DEFINITIONS["backend"], source=relative)
|
||||
for name in ("rawSeriesBytes", "responseBodyBytes"):
|
||||
self.observe(run, "size", name, finite_field(data, name, context, positive=True), "bytes", source=relative)
|
||||
phases = data.get("existingPerformance", {}).get("phases", {})
|
||||
for name, phase in phases.items():
|
||||
self.observe(run, "backend_existing", name, finite_field(phase, "inclusiveNs", context) / 1e6,
|
||||
parent="httpTotalMs", denominator=http,
|
||||
inclusion="Inclusive duration without aligned start/end; not an exclusive extra cost", source=relative)
|
||||
return {"source": relative, "xmlSha256": data.get("xmlSha256"), "httpTotalMs": http,
|
||||
"spans": spans, "nativeStages": c, "process": data.get("process"), "build": data.get("build")}
|
||||
|
||||
def group(self, name: str, mode: str, backend_root: str | None) -> dict:
|
||||
relative = f"browser-{name}/summary.json"
|
||||
data = self.read(relative)
|
||||
require(data.get("errors") == [], f"{name}: missing errors list or reported browser errors")
|
||||
rows = data.get("rows", [])
|
||||
require(len(rows) == 4, f"Incomplete {name}: expected 1 warmup + 3 measured rows, got {len(rows)}")
|
||||
require(sorted(r.get("run", -1) for r in rows) == [0, 1, 2, 3], f"{name}: unexpected/duplicate run numbers")
|
||||
runs = []
|
||||
for row in sorted(rows, key=lambda r: r["run"]):
|
||||
context = f"{name}/{row['run']}"
|
||||
require(row.get("mode") == mode and row.get("deep") is False, f"{context}: wrong instrumentation mode")
|
||||
require(row.get("warmup") is (row["run"] == 0), f"{context}: warmup label mismatch")
|
||||
sid = row.get("simulationId")
|
||||
require(isinstance(sid, str) and bool(sid) and sid not in self.simulation_ids, f"{context}: missing/duplicate simulationId")
|
||||
self.simulation_ids.add(sid)
|
||||
native = row.get("native", {})
|
||||
require(native.get("success") is True and native.get("status") == "completed", f"{context}: native simulation failed")
|
||||
require(row.get("restoredIdentical") is True, f"{context}: restore parity was not confirmed")
|
||||
require(type(native.get("cacheHit")) is bool, f"{context}: missing cacheHit")
|
||||
if row["run"]:
|
||||
require(native["cacheHit"], f"{context}: measured run includes a cold build")
|
||||
for key in NATIVE_IDENTITY:
|
||||
require(key in native and native[key] is not None, f"{context}: missing native {key}")
|
||||
for key in COUNTERS:
|
||||
value = finite_field(native, key, context)
|
||||
require(int(value) == value, f"{context}: noninteger counter {key}")
|
||||
run = {"group": name, "phase": "warmup" if row["warmup"] else "measured", "run": row["run"],
|
||||
"simulationId": sid, "native": native, "original": row,
|
||||
"sampleCount": finite_field(row, "sampleCount", context, positive=True),
|
||||
"variableCount": finite_field(row, "variableCount", context, positive=True)}
|
||||
for key in GOALS.values():
|
||||
finite_field(row, key, context, positive=True)
|
||||
if mode == "profiled":
|
||||
for key in ("resultParseMs", "synchronousStreamDecodeMs", "clickToIndexedDbCommitMs", "streamBytes"):
|
||||
finite_field(row, key, context, positive=True)
|
||||
for key, value in row.items():
|
||||
if key.endswith(("Ms", "Bytes")) and (value is None or numeric(value)):
|
||||
self.observe(run, "frontend", key, value, "bytes" if key.endswith("Bytes") else "ms", source=relative)
|
||||
for key in ("solveSeconds", "solveCpuSeconds", "processWallSeconds", "buildSeconds"):
|
||||
self.observe(run, "native", key.removesuffix("Seconds") + "Ms", finite_field(native, key, context) * 1000,
|
||||
source=relative, inclusion=DEFINITIONS["process"] if key == "processWallSeconds" else "Native-reported timing; CPU and wall are separate")
|
||||
run["backend"] = self.backend(run, f"{backend_root}/{sid}/stages.json") if backend_root else None
|
||||
runs.append(run)
|
||||
for key in ("inputSha256", "buildAssetSetSha256"):
|
||||
value = data.get(key)
|
||||
require(isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value), f"{name}: missing/invalid {key}")
|
||||
require(bool(data.get("servedAssets")), f"{name}: missing served frontend assets")
|
||||
return {"source": relative, "mode": mode, "inputSha256": data["inputSha256"],
|
||||
"buildAssetSetSha256": data["buildAssetSetSha256"], "servedAssets": data["servedAssets"],
|
||||
"browser": data.get("browser"), "node": data.get("node"), "scriptSha256": data.get("scriptSha256"),
|
||||
"sourceDefinitions": data.get("definitions"), "measuredRunCount": 3, "warmupRunCount": 1, "runs": runs}
|
||||
|
||||
def comparisons(self, groups: dict, before: str, after: str, metrics: dict, *, diagnostic: bool) -> list[dict]:
|
||||
comparison = []
|
||||
for target, (source_domain, metric) in metrics.items():
|
||||
summaries = []
|
||||
for name in (before, after):
|
||||
rows = [o for o in self.observations if o["group"] == name and o["phase"] == "measured"
|
||||
and o["domain"] == source_domain and o["metric"] == metric]
|
||||
require(len(rows) == 3 and all(numeric(r["value"]) and r["value"] > 0 for r in rows),
|
||||
f"Missing comparison metric {name}/{target}")
|
||||
summaries.append(stats([r["value"] for r in sorted(rows, key=lambda r: r["run"])]))
|
||||
baseline, optimized = summaries
|
||||
old, new = baseline["median"], optimized["median"]
|
||||
comparison.append({"target": target, "metric": f"{source_domain}.{metric}", "before": before, "after": after,
|
||||
"diagnosticOnly": diagnostic, "statistic": "ratio_of_group_medians", "definition": DEFINITIONS["comparison"],
|
||||
"source": f"{groups[before]['source']} | {groups[after]['source']}",
|
||||
"baselineMs": baseline, "optimizedMs": optimized, "savedMs": old - new,
|
||||
"durationReductionPercent": (old - new) / old * 100, "speedupRatio": old / new})
|
||||
return comparison
|
||||
|
||||
def replay(self) -> dict:
|
||||
relative = "replay/summary.json"
|
||||
data = self.read(relative)
|
||||
require(data.get("allRealFileBinary64Parity") is True, "Replay real-file binary64 verification not complete")
|
||||
supplied = data.get("medians", {})
|
||||
require(bool(supplied.get("file")), "Replay real-file medians missing")
|
||||
runs = data.get("runs", [])
|
||||
require(bool(runs), "Replay run metadata missing")
|
||||
for row in runs:
|
||||
require(row.get("success") is True, "Replay includes a failed run")
|
||||
run = {"group": f"replay:{row['sink']}:{row['variant']}", "phase": "warmup" if row["warmup"] else "measured",
|
||||
"run": row["run"], "simulationId": ""}
|
||||
for metric, unit in (("wallSeconds", "ms"), ("cpuSeconds", "ms"), ("encodedBytes", "bytes")):
|
||||
value = finite_field(row, metric, run["group"], positive=True)
|
||||
self.observe(run, "replay", metric.removesuffix("Seconds") + "Ms" if unit == "ms" else metric,
|
||||
value * 1000 if unit == "ms" else value, unit, source=relative, inclusion=DEFINITIONS["replay"])
|
||||
medians = {sink: {variant: {k: value[k] for k in ("wallSeconds", "cpuSeconds", "encodedBytes")}
|
||||
for variant, value in variants.items()} for sink, variants in supplied.items()}
|
||||
return {"source": relative, "suppliedMedians": medians, "runs": runs,
|
||||
"timingContract": data.get("prepared", {}).get("timingContract"), "limitation": data.get("limitation"),
|
||||
"usedForEndToEndComparison": False}
|
||||
|
||||
def build(self) -> dict:
|
||||
groups = {name: self.group(name, *config) for name, config in GROUPS.items()}
|
||||
reference = groups["baseline"]
|
||||
first = reference["runs"][0]
|
||||
for name, group in groups.items():
|
||||
for key in ("inputSha256", "buildAssetSetSha256", "browser", "node"):
|
||||
require(group[key] is not None and group[key] == reference[key], f"Cross-group {key} mismatch: {name}")
|
||||
for run in group["runs"]:
|
||||
for key in ("sampleCount", "variableCount"):
|
||||
require(run[key] == first[key], f"Cross-run {key} mismatch: {name}/{run['run']}")
|
||||
for key in NATIVE_IDENTITY:
|
||||
require(run["native"][key] == first["native"][key], f"Cross-run native {key} mismatch: {name}/{run['run']}")
|
||||
xml_hashes = [run["backend"]["xmlSha256"] for group in groups.values() for run in group["runs"] if run["backend"]]
|
||||
require(all(isinstance(value, str) and len(value) == 64 for value in xml_hashes) and len(set(xml_hashes)) == 1,
|
||||
"Profiled input XML SHA missing or mismatched")
|
||||
goals = self.comparisons(groups, "baseline", "optimized",
|
||||
{key: ("frontend", metric) for key, metric in GOALS.items()}, diagnostic=False)
|
||||
stages = self.comparisons(groups, "baseline-profiled", "optimized-profiled",
|
||||
{"write_wall": ("c_wall", "jsonWriteMs"), "write_cpu": ("c_cpu", "jsonWriteCpuMs")}, diagnostic=True)
|
||||
replay = self.replay()
|
||||
buckets: dict[tuple, list[dict]] = {}
|
||||
keys = ("group", "phase", "domain", "metric", "unit", "parent")
|
||||
for observation in self.observations:
|
||||
buckets.setdefault(tuple(observation[key] for key in keys), []).append(observation)
|
||||
aggregates = []
|
||||
for key, rows in sorted(buckets.items()):
|
||||
require(len({r["run"] for r in rows}) == len(rows), f"Duplicate per-run observation: {key}")
|
||||
aggregates.append(dict(zip(keys, key)) | stats([r["value"] for r in rows]) | {
|
||||
"percentOfParent": stats([r["percentOfParent"] for r in rows]),
|
||||
"runs": [{k: row[k] for k in ("run", "simulationId")} for row in rows]})
|
||||
return {"schemaVersion": 1, "complete": True, "errors": [], "experimentRoot": str(self.root),
|
||||
"scriptSha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "definitions": DEFINITIONS,
|
||||
"sourceFiles": self.sources, "groups": groups, "changes": {"endToEnd": goals, "cWriteDiagnostics": stages},
|
||||
"replay": replay, "observations": self.observations, "aggregates": aggregates,
|
||||
"validation": {"groupCount": 4, "browserRunCount": 16, "warmupCount": 4, "measuredCount": 12,
|
||||
"inputSha256": reference["inputSha256"], "frontendAssetSetSha256": reference["buildAssetSetSha256"],
|
||||
"profiledXmlSha256": xml_hashes[0], "sampleCount": first["sampleCount"], "variableCount": first["variableCount"],
|
||||
"nativeIdentity": {key: first["native"][key] for key in NATIVE_IDENTITY},
|
||||
"fullNumericalParityIndependentlyChecked": False}}
|
||||
|
||||
|
||||
def write_outputs(root: Path, summary: dict) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
with (root / "timings.csv").open("w", encoding="utf-8", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in summary.get("observations", []):
|
||||
writer.writerow(row | {"statistic": "run", "count": int(numeric(row["value"])), "missingCount": int(row["value"] is None)})
|
||||
for entry in summary.get("aggregates", []):
|
||||
for statistic in ("median", "min", "max"):
|
||||
writer.writerow({k: entry[k] for k in ("group", "phase", "domain", "metric", "unit", "parent", "count", "missingCount")} | {
|
||||
"statistic": statistic, "value": entry[statistic], "percentOfParent": entry["percentOfParent"][statistic],
|
||||
"inclusion": "Per-run values and percentages aggregated separately; medians are not additive"})
|
||||
for category, changes in summary.get("changes", {}).items():
|
||||
for change in changes:
|
||||
for key, unit in (("durationReductionPercent", "percent"), ("speedupRatio", "ratio"), ("savedMs", "ms")):
|
||||
writer.writerow({"group": f"{change['after']}-vs-{change['before']}", "phase": "measured",
|
||||
"domain": f"comparison:{category}", "metric": f"{change['target']}.{key}",
|
||||
"statistic": "ratio_of_group_medians" if key != "savedMs" else "difference_of_group_medians",
|
||||
"value": change[key], "unit": unit, "baselineValue": change["baselineMs"]["median"],
|
||||
"optimizedValue": change["optimizedMs"]["median"], "count": 3, "missingCount": 0,
|
||||
"inclusion": ("Profiled C-write diagnostic only; " if change["diagnosticOnly"] else "") + change["definition"],
|
||||
"source": change["source"]})
|
||||
(root / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=REPO / "test/c-result-encoding-20260911")
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
if not root.is_relative_to(REPO / "test"):
|
||||
parser.error("--root must be beneath the repository's ignored test/ directory")
|
||||
summarizer = Summarizer(root)
|
||||
try:
|
||||
summary = summarizer.build()
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
summary = {"schemaVersion": 1, "complete": False, "errors": [str(error)],
|
||||
"experimentRoot": str(root), "sourceFiles": summarizer.sources,
|
||||
"note": "No partial timing statistics are published. Complete/fix all groups and rerun."}
|
||||
write_outputs(root, summary)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 2
|
||||
write_outputs(root, summary)
|
||||
print(json.dumps({"complete": True, "validation": summary["validation"],
|
||||
"observations": len(summary["observations"]), "outputs": [str(root / name) for name in ("summary.json", "timings.csv")]}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Summarize one web-cost experiment without reading numerical result files.
|
||||
|
||||
Usage: .venv/bin/python tests/manual/summarize_web_cost.py --root test/web-cost-20260911
|
||||
Only small summary/trace/stages/environment JSON files are read. Warmups and
|
||||
control/profiled groups stay separate. This script does not compute speedups.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
AXIS = (
|
||||
("runClick", "click"), ("fetchStart", "fetch"), ("headers", "headers"),
|
||||
("lastChunk", "last_chunk"), ("resultParseStart", "parse_start"),
|
||||
("resultParseEnd", "parse_end"), ("streamEof", "eof"),
|
||||
("resultReadyDom", "ready"), ("indexedDbCommittedPointer", "commit"),
|
||||
)
|
||||
# Prune the reporting tree at these declared boundaries. The indexed-read and
|
||||
# process-lifetime spans are atomic ONLY in this coarse HTTP partition; their
|
||||
# measured children remain visible in the separate inclusive span hierarchy.
|
||||
HTTP_BOUNDARIES = {
|
||||
"xml_validation", "network_compilation", "c_generation",
|
||||
"native_build_or_cache_validation", "native_process_lifetime_observed",
|
||||
"native_indexed_result_read", "profile_artifact_preservation",
|
||||
"response_result_json_serialization",
|
||||
}
|
||||
C_WALL_FIELDS = (
|
||||
"argumentPreparationSeconds", "initializationSeconds", "integrationSeconds",
|
||||
"finalSampleAndStatusSeconds", "projectionSeconds", "jsonWriteSeconds",
|
||||
)
|
||||
DEFINITIONS = {
|
||||
"scope": "One current experiment; control/profiled are observation modes, not before/after implementations. No speedup is computed.",
|
||||
"statistics": "Median/min/max are calculated from individual runs. Percentages divide each run by its own stated denominator before aggregation. Medians need not add to a median total.",
|
||||
"warmup": "All warmup rows are retained separately and excluded from measured statistics. Cold build is identified by cacheHit=false, not by the warmup label.",
|
||||
"frontendClock": "Browser performance.now() within one trace timeOrigin. Reload/restore has another time axis; no timestamps are subtracted across documents or across browser/backend clocks.",
|
||||
"waterfall": "Adjacent requested marks are subtracted without clipping negatives. Missing marks remain null; commit is the instrumented pointer publication, never replaced by the control polling mark.",
|
||||
"waterfallPercent": "Each adjacent interval / same-run click-to-commit. Negative intervals remain negative and reveal overlapping completion order; they are not exclusive CPU costs.",
|
||||
"backendClock": "Backend spans use request-relative perf_counter_ns. Parent links are inferred by interval containment and indicate inclusive wall intervals, not a traced call stack or exclusive CPU work.",
|
||||
"httpPartition": "Pruned reporting leaves use HTTP_BOUNDARIES, checked for overlap and containment before partitioning. Their union is subtracted from HTTP total to produce unnamed other time. Spawn/read/parse children must not be added again. Response assembly has a duration but no aligned timestamp and remains in other.",
|
||||
"backendOther": "Unclassified HTTP wall intervals include uninstrumented work, scheduling, response assembly, inter-stage gaps and sending. They are not all transport time or CPU work.",
|
||||
"process": "Observed lifetime runs from Popen entry to the first existing poll/wait reporting exit; includes spawn and exit-observation delay. The result processWallSeconds also includes Python result reading. Child CPU is a RUSAGE_CHILDREN delta and assumes no unrelated child is reaped in the same parent interval.",
|
||||
"cInitialization": "The separately measured initialization covers model_init and first sample. CVODE allocation/init/reinit and cleanup are inside integration, along with RHS, Jacobian/linear solve, events and sampling.",
|
||||
"cOutput": "Projection includes final/sample model_eval and allocation. JSON write includes float formatting, stdio, file close and index writing. CPU and wall are separately reported; their difference is not a measured disk-I/O stage.",
|
||||
"cParent": "C phase wall percentages use same-run C main wall; CPU percentages use the same-run observed child user+system CPU. Initial/final/remaining CPU is not individually measured.",
|
||||
"stream": "Read-wait wall overlaps backend production/transport/browser scheduling. Decode and JSON.parse are within headers-to-EOF. NDJSON scanning/join/trim, callbacks, GC and scheduler time are not independently timed.",
|
||||
"persistence": "Save preparation/IndexedDB run in the background with readiness and rendering. Transaction windows include asynchronous waiting; pointer commit is publication after successful writes, control observation uses polling.",
|
||||
"renderExport": "Two requestAnimationFrame callbacks give a paint opportunity, not GPU completion. Download completion includes automation delivery and saveAs. CSV Worker transfer/preparation overlaps Worker activity; finish-post-to-receipt is not isolated Worker CPU.",
|
||||
"unmeasured": "No exclusive breakdown of click preprocessing, NDJSON join/trim, React/GC, CVODE internals, C formatting versus file writes, browser network stack or GPU work is invented. Deeper/native-only experiments are intentionally not read here.",
|
||||
}
|
||||
|
||||
|
||||
def number(value: Any) -> bool:
|
||||
return type(value) in (int, float) and math.isfinite(value)
|
||||
|
||||
|
||||
def ratio(value: Any, denominator: Any) -> float | None:
|
||||
return 100.0 * value / denominator if number(value) and number(denominator) and denominator > 0 else None
|
||||
|
||||
|
||||
def difference(marks: dict, left: str, right: str) -> float | None:
|
||||
a, b = marks.get(left), marks.get(right)
|
||||
return b - a if number(a) and number(b) else None
|
||||
|
||||
|
||||
def statistics(values: list[Any]) -> dict:
|
||||
valid = [float(value) for value in values if number(value)]
|
||||
return {"count": len(valid), "missingCount": len(values) - len(valid),
|
||||
"median": median(valid) if valid else None,
|
||||
"min": min(valid) if valid else None, "max": max(valid) if valid else None,
|
||||
"values": values}
|
||||
|
||||
|
||||
def union_length(intervals: list[tuple[float, float]]) -> float:
|
||||
total, end = 0.0, -math.inf
|
||||
for start, stop in sorted(intervals):
|
||||
total += max(0.0, stop - max(start, end))
|
||||
end = max(end, stop)
|
||||
return total
|
||||
|
||||
|
||||
def span_hierarchy(raw_spans: list[dict], http_ms: float) -> list[dict]:
|
||||
spans = [{"id": "http", "name": "http_total", "startMs": 0.0,
|
||||
"endMs": http_ms, "durationMs": http_ms, "source": "httpTotalSeconds"}]
|
||||
for index, span in enumerate(raw_spans):
|
||||
start, end = span.get("startMs"), span.get("endMs")
|
||||
if not number(start) or not number(end) or end < start:
|
||||
raise ValueError(f"Invalid backend span: {span}")
|
||||
spans.append({"id": f"span-{index}", "name": span["name"], "startMs": start,
|
||||
"endMs": end, "durationMs": end - start,
|
||||
"recordedSeconds": span.get("seconds"), "source": "spans"})
|
||||
for current in spans:
|
||||
candidates = [other for other in spans if other["id"] != current["id"]
|
||||
and other["startMs"] <= current["startMs"]
|
||||
and other["endMs"] >= current["endMs"]
|
||||
and (other["durationMs"] > current["durationMs"]
|
||||
or other["id"] == "http")]
|
||||
parent = min(candidates, key=lambda s: s["durationMs"]) if current["id"] != "http" and candidates else None
|
||||
current["parentId"] = parent["id"] if parent else None
|
||||
current["parentName"] = parent["name"] if parent else None
|
||||
current["percentOfParent"] = ratio(current["durationMs"], parent["durationMs"]) if parent else None
|
||||
current["percentOfHttp"] = ratio(current["durationMs"], http_ms)
|
||||
current["inclusion"] = "inclusive interval; do not add its children"
|
||||
for current in spans:
|
||||
children = [s for s in spans if s["parentId"] == current["id"]]
|
||||
current["children"] = [s["id"] for s in children]
|
||||
current["uncoveredByDirectChildrenMs"] = current["durationMs"] - union_length(
|
||||
[(s["startMs"], s["endMs"]) for s in children])
|
||||
current["partiallyOverlaps"] = [s["id"] for s in spans if s["id"] != current["id"]
|
||||
and max(s["startMs"], current["startMs"]) < min(s["endMs"], current["endMs"])
|
||||
and not (s["startMs"] <= current["startMs"] and s["endMs"] >= current["endMs"])
|
||||
and not (current["startMs"] <= s["startMs"] and current["endMs"] >= s["endMs"])]
|
||||
return spans
|
||||
|
||||
|
||||
class Summary:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root.resolve()
|
||||
self.sources: dict[str, dict] = {}
|
||||
self.observations: list[dict] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def read(self, path: Path) -> dict:
|
||||
path = path.resolve()
|
||||
path.relative_to(self.root)
|
||||
size = path.stat().st_size
|
||||
if size > 4 * 1024 * 1024:
|
||||
raise ValueError(f"Expected small metadata JSON, refusing {path} ({size} bytes)")
|
||||
raw = path.read_bytes()
|
||||
self.sources[str(path.relative_to(self.root))] = {
|
||||
"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw)}
|
||||
return json.loads(raw)
|
||||
|
||||
def observe(self, run: dict, domain: str, metric: str, value: Any,
|
||||
unit: str = "ms", *, parent: str = "", denominator: Any = None,
|
||||
inclusion: str = "inclusive or overlapping; not additive", source: str = "") -> None:
|
||||
if value is not None and not number(value):
|
||||
raise ValueError(f"Non-numeric metric {domain}.{metric}: {value!r}")
|
||||
self.observations.append({"group": run["group"], "phase": run["phase"],
|
||||
"run": run["run"], "simulationId": run["simulationId"], "domain": domain,
|
||||
"metric": metric, "value": value, "unit": unit, "parent": parent,
|
||||
"denominatorValue": denominator if number(denominator) else None,
|
||||
"percentOfParent": ratio(value, denominator), "inclusion": inclusion, "source": source})
|
||||
|
||||
def backend(self, run: dict, data: dict, source: str) -> dict:
|
||||
if data.get("id") != run["simulationId"]:
|
||||
raise ValueError(f"Simulation ID mismatch in {source}")
|
||||
http_ms = data["httpTotalSeconds"] * 1000
|
||||
hierarchy = span_hierarchy(data.get("spans", []), http_ms)
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for span in hierarchy:
|
||||
by_name.setdefault(span["name"], []).append(span)
|
||||
for name, occurrences in by_name.items():
|
||||
parent_names = sorted({s["parentName"] or "" for s in occurrences})
|
||||
self.observe(run, "backend_spans", name, sum(s["durationMs"] for s in occurrences),
|
||||
parent="http_total", denominator=http_ms, source=source,
|
||||
inclusion=f"inclusive sum of {len(occurrences)} call(s); interval parents: {', '.join(parent_names)}")
|
||||
chosen = [s for s in hierarchy if s["name"] in HTTP_BOUNDARIES]
|
||||
intervals = [(s["startMs"], s["endMs"]) for s in chosen]
|
||||
covered = union_length(intervals)
|
||||
overlaps = sum(stop - start for start, stop in intervals) - covered
|
||||
outside = [s["id"] for s in chosen if s["startMs"] < 0 or s["endMs"] > http_ms]
|
||||
partition_ok = overlaps <= 1e-6 and not outside
|
||||
partition = {"valid": partition_ok, "scope": "http_total", "totalMs": http_ms,
|
||||
"selectedSpanIds": [s["id"] for s in chosen], "measuredUnionMs": covered,
|
||||
"overlapMs": overlaps, "outsideHttpSpanIds": outside,
|
||||
"otherMs": http_ms - covered if not outside else None,
|
||||
"note": DEFINITIONS["httpPartition"], "segments": []}
|
||||
if partition_ok:
|
||||
partition_durations: dict[str, float] = {}
|
||||
for span in sorted(chosen, key=lambda s: s["startMs"]):
|
||||
partition["segments"].append({"metric": span["name"], "startMs": span["startMs"],
|
||||
"endMs": span["endMs"], "durationMs": span["durationMs"],
|
||||
"percentOfHttp": ratio(span["durationMs"], http_ms)})
|
||||
partition_durations[span["name"]] = partition_durations.get(span["name"], 0) + span["durationMs"]
|
||||
for name, duration in partition_durations.items():
|
||||
self.observe(run, "http_partition", name, duration,
|
||||
parent="http_total", denominator=http_ms, inclusion="non-overlapping at declared reporting depth", source=source)
|
||||
self.observe(run, "http_partition", "other_unclassified", partition["otherMs"],
|
||||
parent="http_total", denominator=http_ms, inclusion=DEFINITIONS["backendOther"], source=source)
|
||||
else:
|
||||
self.warnings.append(f"{run['simulationId']}: HTTP partition disabled; selected spans overlap or leave request bounds")
|
||||
for key, value in data.items():
|
||||
if key.endswith("Ms") or key in ("responseBodyBytes", "rawSeriesBytes", "xmlBytes", "sampleCount"):
|
||||
self.observe(run, "backend_metrics", key, value,
|
||||
"ms" if key.endswith("Ms") else "bytes" if key.endswith("Bytes") else "count", source=source)
|
||||
elif key == "responseSendAwaitSeconds":
|
||||
self.observe(run, "backend_metrics", key, value * 1000,
|
||||
inclusion="ASGI send waits overlap HTTP and are not pure network time", source=source)
|
||||
phases = data.get("existingPerformance", {}).get("phases", {})
|
||||
for key, phase in phases.items():
|
||||
self.observe(run, "backend_existing_performance", key, phase["inclusiveNs"] / 1e6,
|
||||
parent="http_total", denominator=http_ms, source=source,
|
||||
inclusion="inclusive duration without aligned start/end; excluded from HTTP partition")
|
||||
c = dict(data.get("nativeStages", {}))
|
||||
native = data.get("native", {})
|
||||
c_main = c.get("mainTotalSeconds")
|
||||
for key in C_WALL_FIELDS:
|
||||
value = c.get(key)
|
||||
self.observe(run, "c_wall", key, value * 1000 if number(value) else None,
|
||||
parent="c_main_wall", denominator=c_main * 1000 if number(c_main) else None,
|
||||
inclusion="sequential C phase wall duration; C main lies within observed process lifetime", source=source)
|
||||
self.observe(run, "c_wall", "mainTotalSeconds", c_main * 1000 if number(c_main) else None,
|
||||
inclusion="inclusive C main; does not include loader/exit observation", source=source)
|
||||
c_other = c_main - sum(c[key] for key in C_WALL_FIELDS) if number(c_main) and all(number(c.get(k)) for k in C_WALL_FIELDS) else None
|
||||
self.observe(run, "c_wall", "other_unclassified", c_other * 1000 if number(c_other) else None,
|
||||
parent="c_main_wall", denominator=c_main * 1000 if number(c_main) else None,
|
||||
inclusion="C main minus sequential measured wall phases, without clipping negative differences", source=source)
|
||||
process = dict(data.get("process", {}))
|
||||
child_cpu = (process["childrenUserCpuSeconds"] + process["childrenSystemCpuSeconds"]
|
||||
if all(number(process.get(k)) for k in ("childrenUserCpuSeconds", "childrenSystemCpuSeconds")) else None)
|
||||
for key, value in {"integrationCpuSeconds": native.get("solveCpuSeconds"),
|
||||
"projectionCpuSeconds": c.get("projectionCpuSeconds"),
|
||||
"jsonWriteCpuSeconds": c.get("jsonWriteCpuSeconds")}.items():
|
||||
self.observe(run, "c_cpu", key, value * 1000 if number(value) else None,
|
||||
parent="observed_child_cpu", denominator=child_cpu * 1000 if number(child_cpu) else None,
|
||||
inclusion="CPU duration, separate from wall partition", source=source)
|
||||
for key in ("startMs", "exitObservedMs", "childrenUserCpuSeconds", "childrenSystemCpuSeconds"):
|
||||
value = process.get(key)
|
||||
self.observe(run, "process", key, value * 1000 if number(value) and key.endswith("Seconds") else value,
|
||||
inclusion=DEFINITIONS["process"], source=source)
|
||||
self.observe(run, "process", "observed_lifetime", difference(process, "startMs", "exitObservedMs"),
|
||||
inclusion="includes spawn; excludes subsequent Python result read", source=source)
|
||||
self.observe(run, "process", "observed_child_cpu", child_cpu * 1000 if number(child_cpu) else None,
|
||||
inclusion=DEFINITIONS["process"], source=source)
|
||||
return {"source": source, "httpTotalMs": http_ms, "spanHierarchy": hierarchy,
|
||||
"httpPartition": partition, "cStages": c, "process": process,
|
||||
"build": data.get("build"), "native": native,
|
||||
"existingPerformance": data.get("existingPerformance")}
|
||||
|
||||
def group(self, mode: str, expected_runs: int) -> dict:
|
||||
folder = self.root / f"browser-{mode}"
|
||||
source = folder / "summary.json"
|
||||
summary = self.read(source)
|
||||
rows = summary.get("rows", [])
|
||||
seen: set[str] = set()
|
||||
runs = []
|
||||
for original in rows:
|
||||
if original.get("mode") != mode:
|
||||
raise ValueError(f"Unexpected mode {original.get('mode')!r} in {source}")
|
||||
sid = original["simulationId"]
|
||||
if sid in seen:
|
||||
raise ValueError(f"Duplicate simulationId {sid} in {source}")
|
||||
seen.add(sid)
|
||||
run = {"group": mode, "phase": "warmup" if original.get("warmup") else "measured",
|
||||
"run": original["run"], "simulationId": sid, "original": original}
|
||||
trace_path = folder / original["artifacts"] / "trace.json"
|
||||
trace = self.read(trace_path)
|
||||
if trace.get("timeOrigin") != original.get("timeOrigin"):
|
||||
raise ValueError(f"Browser timeOrigin mismatch: {trace_path}")
|
||||
marks = trace.get("marks", {})
|
||||
if mode == "profiled":
|
||||
request_ids = {r.get("simulationId") for r in trace.get("requests", []) if r.get("kind") == "simulation"}
|
||||
if request_ids != {sid}:
|
||||
raise ValueError(f"Browser trace request ID mismatch: {trace_path}")
|
||||
total = difference(marks, "runClick", "indexedDbCommittedPointer")
|
||||
waterfall = []
|
||||
for (left, left_name), (right, right_name) in zip(AXIS, AXIS[1:]):
|
||||
value = difference(marks, left, right)
|
||||
metric = f"{left_name}_to_{right_name}"
|
||||
entry = {"metric": metric, "fromMark": left, "toMark": right,
|
||||
"startMs": marks.get(left), "endMs": marks.get(right), "durationMs": value,
|
||||
"percentOfClickToCommit": ratio(value, total), "negative": value is not None and value < 0}
|
||||
waterfall.append(entry)
|
||||
self.observe(run, "frontend_waterfall", metric, value, parent="click_to_commit",
|
||||
denominator=total, source=str(trace_path.relative_to(self.root)), inclusion=DEFINITIONS["waterfall"])
|
||||
complete = all(number(part["durationMs"]) for part in waterfall)
|
||||
if complete and not math.isclose(sum(p["durationMs"] for p in waterfall), total, abs_tol=1e-6):
|
||||
raise ValueError(f"Waterfall does not telescope for {sid}")
|
||||
run["frontend"] = {"timeOrigin": trace["timeOrigin"], "marks": marks,
|
||||
"waterfall": waterfall, "clickToCommitMs": total, "waterfallComplete": complete,
|
||||
"negativeIntervals": [part["metric"] for part in waterfall if part["negative"]]}
|
||||
for key, value in original.items():
|
||||
if key in ("run", "timeOrigin") or isinstance(value, bool):
|
||||
continue
|
||||
if number(value) or value is None:
|
||||
unit = "ms" if key.endswith("Ms") else "bytes" if key.endswith("Bytes") else "count" if key.endswith("Count") else "number"
|
||||
self.observe(run, "frontend_metrics", key, value, unit, source=str(source.relative_to(self.root)))
|
||||
native = original.get("native", {})
|
||||
for key, value in native.items():
|
||||
if number(value):
|
||||
unit = "ms" if key.endswith("Seconds") else "simulated_s" if key in ("simulatedUntil", "maxAcceptedStep") else "count"
|
||||
self.observe(run, "native_reported", key, value * 1000 if key.endswith("Seconds") else value, unit,
|
||||
source=str(source.relative_to(self.root)), inclusion="original browser native diagnostic; processWallSeconds includes result read")
|
||||
run["buildCost"] = {"cacheHit": native.get("cacheHit"), "buildKey": native.get("buildKey"),
|
||||
"reportedSeconds": native.get("buildSeconds"),
|
||||
"classification": "cache_hit" if native.get("cacheHit") is True else "cold_build" if native.get("cacheHit") is False else "unknown"}
|
||||
self.observe(run, "build", run["buildCost"]["classification"],
|
||||
native["buildSeconds"] * 1000 if number(native.get("buildSeconds")) else None,
|
||||
source=str(source.relative_to(self.root)), inclusion="warmups separate; cacheHit determines cold/cache classification")
|
||||
backend_path = self.root / "backend-profiled/requests" / sid / "stages.json"
|
||||
if mode == "profiled" and backend_path.exists():
|
||||
backend_data = self.read(backend_path)
|
||||
run["backend"] = self.backend(run, backend_data, str(backend_path.relative_to(self.root)))
|
||||
for key in ("buildKey", "nfev", "acceptedSteps", "solveSeconds"):
|
||||
if backend_data.get("native", {}).get(key) != native.get(key):
|
||||
raise ValueError(f"Browser/backend native diagnostic mismatch for {sid}: {key}")
|
||||
else:
|
||||
run["backend"] = None
|
||||
if mode == "profiled": self.warnings.append(f"{sid}: completed browser row has no backend stages.json yet")
|
||||
runs.append(run)
|
||||
measured = sum(r["phase"] == "measured" for r in runs)
|
||||
if measured != expected_runs:
|
||||
self.warnings.append(f"{mode}: {measured} measured run(s), expected {expected_runs}")
|
||||
if summary.get("errors"):
|
||||
self.warnings.append(f"{mode}: browser summary contains errors; inspect source before interpreting results")
|
||||
environment = self.root / f"backend-{mode}" / "environment.json"
|
||||
return {"source": str(source.relative_to(self.root)), "browser": summary.get("browser"),
|
||||
"node": summary.get("node"), "input": summary.get("input"), "inputSha256": summary.get("inputSha256"),
|
||||
"buildAssetSetSha256": summary.get("buildAssetSetSha256"), "servedAssets": summary.get("servedAssets"),
|
||||
"sourceDefinitions": summary.get("definitions"), "initialNavigation": summary.get("initialNavigation"),
|
||||
"backendEnvironment": self.read(environment) if environment.exists() else None,
|
||||
"measuredRunCount": measured, "warmupRunCount": len(runs) - measured, "runs": runs}
|
||||
|
||||
def aggregate(self) -> list[dict]:
|
||||
buckets: dict[tuple, list[dict]] = {}
|
||||
for observation in self.observations:
|
||||
key = tuple(observation[k] for k in ("group", "phase", "domain", "metric", "unit", "parent"))
|
||||
buckets.setdefault(key, []).append(observation)
|
||||
result = []
|
||||
for key, rows in sorted(buckets.items()):
|
||||
entry = dict(zip(("group", "phase", "domain", "metric", "unit", "parent"), key))
|
||||
entry.update(statistics([r["value"] for r in rows]))
|
||||
entry["percentOfParent"] = statistics([r["percentOfParent"] for r in rows])
|
||||
entry["runs"] = [{"run": r["run"], "simulationId": r["simulationId"]} for r in rows]
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
def write(self, expected_runs: int) -> dict:
|
||||
groups = {mode: self.group(mode, expected_runs) for mode in ("control", "profiled")}
|
||||
if groups["control"]["inputSha256"] != groups["profiled"]["inputSha256"]:
|
||||
raise ValueError("Control/profiled input SHA mismatch")
|
||||
if groups["control"]["buildAssetSetSha256"] != groups["profiled"]["buildAssetSetSha256"]:
|
||||
self.warnings.append("Control/profiled frontend asset sets differ")
|
||||
aggregates = self.aggregate()
|
||||
build_costs = [{"group": mode, "run": r["run"], "phase": r["phase"],
|
||||
"simulationId": r["simulationId"], **r["buildCost"]}
|
||||
for mode, group in groups.items() for r in group["runs"]]
|
||||
output = {"schemaVersion": 1, "experimentRoot": str(self.root),
|
||||
"script": str(Path(__file__).resolve().relative_to(REPO)),
|
||||
"scriptSha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
"definitions": DEFINITIONS, "warnings": self.warnings, "sourceFiles": self.sources,
|
||||
"groups": groups, "observations": self.observations, "aggregates": aggregates,
|
||||
"complete": not self.warnings, "buildCosts": build_costs,
|
||||
"warmupBuildCosts": [cost for cost in build_costs if cost["phase"] == "warmup"],
|
||||
"coldBuildCosts": [cost for cost in build_costs if cost["classification"] == "cold_build"]}
|
||||
(self.root / "summary.json").write_text(json.dumps(output, ensure_ascii=False, indent=2, allow_nan=False) + "\n")
|
||||
fields = ["group", "phase", "run", "simulationId", "domain", "metric", "statistic", "value", "unit",
|
||||
"parent", "denominatorValue", "percentOfParent", "count", "missingCount", "inclusion", "source"]
|
||||
with (self.root / "timings.csv").open("w", encoding="utf-8", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for row in self.observations:
|
||||
writer.writerow({**row, "statistic": "run", "count": int(number(row["value"])), "missingCount": int(row["value"] is None)})
|
||||
for entry in aggregates:
|
||||
for stat in ("median", "min", "max"):
|
||||
writer.writerow({**{key: entry[key] for key in ("group", "phase", "domain", "metric", "unit", "parent")},
|
||||
"statistic": stat, "value": entry[stat], "percentOfParent": entry["percentOfParent"][stat],
|
||||
"count": entry["count"], "missingCount": entry["missingCount"],
|
||||
"inclusion": "values and same-run percentages aggregated separately; do not add medians"})
|
||||
return output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=REPO / "test/web-cost-20260911")
|
||||
parser.add_argument("--expected-runs", type=int, default=3)
|
||||
args = parser.parse_args()
|
||||
if args.expected_runs < 1:
|
||||
parser.error("--expected-runs must be positive")
|
||||
summary = Summary(args.root).write(args.expected_runs)
|
||||
print(json.dumps({"root": summary["experimentRoot"], "complete": summary["complete"],
|
||||
"runs": {mode: {"measured": value["measuredRunCount"], "warmup": value["warmupRunCount"]}
|
||||
for mode, value in summary["groups"].items()},
|
||||
"observations": len(summary["observations"]), "warnings": summary["warnings"]}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Standalone JSON-number contracts; no model, solver or SUNDIALS is required.
|
||||
|
||||
The decimal text may change between correct shortest encoders. These tests use
|
||||
Python's independent JSON decoder and binary64 bits, including the sign of zero,
|
||||
instead of comparing against the old %.17g spelling.
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JSON_NUMBER = re.compile(rb'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z')
|
||||
BUFFER_BYTES = 64 * 1024
|
||||
SIGN = 1 << 63
|
||||
FRACTION_MASK = (1 << 52) - 1
|
||||
|
||||
|
||||
def double_from_bits(bits):
|
||||
return struct.unpack('=d', struct.pack('=Q', bits))[0]
|
||||
|
||||
|
||||
def double_bits(value):
|
||||
return struct.unpack('=Q', struct.pack('=d', value))[0]
|
||||
|
||||
|
||||
def finite_patterns(random_count=10000):
|
||||
# Every finite exponent binade, with exact powers and significand edges.
|
||||
values = {0, SIGN}
|
||||
for exponent in range(0x7ff):
|
||||
for fraction in (0, 1, (1 << 51) - 1, 1 << 51, FRACTION_MASK):
|
||||
bits = (exponent << 52) | fraction
|
||||
values.update((bits, bits | SIGN))
|
||||
# Decimal carry/notation boundaries and adjacent representable values.
|
||||
for exponent in range(-323, 309):
|
||||
center = float(f'1e{exponent}')
|
||||
for value in (math.nextafter(center, 0), center, math.nextafter(center, math.inf)):
|
||||
if math.isfinite(value):
|
||||
values.update((double_bits(value), double_bits(-value)))
|
||||
random_source = random.Random(0x5259555F4A534F4E)
|
||||
added = 0
|
||||
while added < random_count:
|
||||
bits = random_source.getrandbits(64)
|
||||
if (bits >> 52) & 0x7ff != 0x7ff:
|
||||
values.add(bits)
|
||||
added += 1
|
||||
return sorted(values)
|
||||
|
||||
|
||||
class WriteStatus(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('opened', ctypes.c_int), ('written', ctypes.c_int), ('closed', ctypes.c_int),
|
||||
('start', ctypes.c_longlong), ('end', ctypes.c_longlong), ('final_position', ctypes.c_longlong),
|
||||
]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include "json_numbers.h"
|
||||
|
||||
/* Faults intercept only the production writer's fwrite calls in this temporary
|
||||
translation unit. Real stdio still writes the accepted prefix. */
|
||||
int test_write_fault_mode = 0;
|
||||
int test_write_call_count = 0;
|
||||
#ifdef TEST_FWRITE_FAULTS
|
||||
static size_t test_fwrite(const void *data, size_t size, size_t count, FILE *stream) {
|
||||
test_write_call_count++;
|
||||
if (test_write_fault_mode == 1 ||
|
||||
(test_write_fault_mode == 3 && test_write_call_count >= 2)) return 0;
|
||||
if (test_write_fault_mode == 2)
|
||||
return count ? fwrite(data, size, count - 1, stream) : 0;
|
||||
return fwrite(data, size, count, stream);
|
||||
}
|
||||
#define fwrite test_fwrite
|
||||
#include "json_numbers.c"
|
||||
#undef fwrite
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int opened, written, closed;
|
||||
long long start, end, final_position;
|
||||
} TestWriteStatus;
|
||||
|
||||
void test_array_file(const char *path, const double *values, size_t count,
|
||||
size_t stride, size_t prefix_bytes, TestWriteStatus *status) {
|
||||
memset(status, 0, sizeof(*status));
|
||||
FILE *stream = fopen(path, "wb");
|
||||
if (!stream) return;
|
||||
status->opened = 1;
|
||||
for (size_t i = 0; i < prefix_bytes; i++) fputc('p', stream);
|
||||
status->start = (long long)ftell(stream);
|
||||
status->written = native_json_write_array(stream, values, count, stride);
|
||||
status->end = (long long)ftell(stream);
|
||||
if (status->written) fputs("TAIL", stream);
|
||||
status->final_position = (long long)ftell(stream);
|
||||
status->closed = fclose(stream) == 0;
|
||||
}
|
||||
|
||||
void test_number_file(const char *path, double value, TestWriteStatus *status) {
|
||||
memset(status, 0, sizeof(*status));
|
||||
FILE *stream = fopen(path, "wb");
|
||||
if (!stream) return;
|
||||
status->opened = 1;
|
||||
status->written = native_json_write_number(stream, value);
|
||||
status->end = status->final_position = (long long)ftell(stream);
|
||||
status->closed = fclose(stream) == 0;
|
||||
}
|
||||
|
||||
/* The number writer flushes its own block to FILE, not FILE's stdio buffer.
|
||||
The caller must still propagate a delayed fclose failure. */
|
||||
int test_delayed_close_failure(const char *path, int *written, int *closed) {
|
||||
FILE *stream = fopen(path, "wb");
|
||||
if (!stream) return 0;
|
||||
char buffer[4096];
|
||||
if (setvbuf(stream, buffer, _IOFBF, sizeof(buffer))) { fclose(stream); return 0; }
|
||||
*written = native_json_write_number(stream, 0.1);
|
||||
*closed = fclose(stream) == 0;
|
||||
return 1;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class NativeJsonWriterTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
command = shlex.split(os.environ.get('CC', ''))
|
||||
if not command:
|
||||
compiler = shutil.which('gcc') or shutil.which('clang')
|
||||
if not compiler:
|
||||
raise unittest.SkipTest('A native C compiler is required')
|
||||
command = [compiler]
|
||||
cls.compiler = command
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix='native-json-writer-')
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.root = Path(cls.directory.name)
|
||||
cls.library = cls.build_library('ordinary', faults=False)
|
||||
cls.fault_library = cls.build_library('faults', faults=True)
|
||||
cls.portable_library = cls.build_library('portable-64-bit', faults=False, only_64_bit=True)
|
||||
cls.fault_mode = ctypes.c_int.in_dll(cls.fault_library, 'test_write_fault_mode')
|
||||
cls.fault_calls = ctypes.c_int.in_dll(cls.fault_library, 'test_write_call_count')
|
||||
|
||||
@classmethod
|
||||
def build_library(cls, name, *, faults, only_64_bit=False):
|
||||
source = cls.root / f'{name}.c'
|
||||
source.write_text(HARNESS)
|
||||
library_path = cls.root / (name + ('.dll' if os.name == 'nt' else '.so'))
|
||||
command = cls.compiler + ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror',
|
||||
'-ffp-contract=off', '-fno-fast-math', '-shared']
|
||||
if os.name != 'nt':
|
||||
command.append('-fPIC')
|
||||
if faults:
|
||||
command.append('-DTEST_FWRITE_FAULTS')
|
||||
if only_64_bit:
|
||||
command.append('-DRYU_ONLY_64_BIT_OPS')
|
||||
command += ['-I', str(ROOT / 'native/include'), '-I', str(ROOT / 'native/runtime'),
|
||||
str(source)]
|
||||
if not faults:
|
||||
command.append(str(ROOT / 'native/runtime/json_numbers.c'))
|
||||
command += [str(ROOT / 'native/encoding/ryu/d2s.c'), '-lm', '-o', str(library_path)]
|
||||
compiled = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if compiled.returncode:
|
||||
raise AssertionError(compiled.stderr)
|
||||
library = ctypes.CDLL(str(library_path))
|
||||
if os.name == 'nt':
|
||||
import _ctypes
|
||||
cls.addClassCleanup(_ctypes.FreeLibrary, library._handle)
|
||||
library.native_json_format_double.argtypes = [ctypes.c_void_p, ctypes.c_double]
|
||||
library.native_json_format_double.restype = ctypes.c_int
|
||||
library.native_json_write_number.argtypes = [ctypes.c_void_p, ctypes.c_double]
|
||||
library.native_json_write_number.restype = ctypes.c_int
|
||||
library.native_json_write_array.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t, ctypes.c_size_t]
|
||||
library.native_json_write_array.restype = ctypes.c_int
|
||||
library.test_array_file.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_double),
|
||||
ctypes.c_size_t, ctypes.c_size_t, ctypes.c_size_t,
|
||||
ctypes.POINTER(WriteStatus)]
|
||||
library.test_array_file.restype = None
|
||||
library.test_number_file.argtypes = [ctypes.c_char_p, ctypes.c_double, ctypes.POINTER(WriteStatus)]
|
||||
library.test_number_file.restype = None
|
||||
library.test_delayed_close_failure.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
|
||||
library.test_delayed_close_failure.restype = ctypes.c_int
|
||||
return library
|
||||
|
||||
def encode(self, value, library=None):
|
||||
# Sentinels bracket the promised 32-byte output, with no assumption that
|
||||
# the returned token is NUL-terminated.
|
||||
storage = (ctypes.c_ubyte * 34)(*([0xA5] * 34))
|
||||
length = (library or self.library).native_json_format_double(ctypes.byref(storage, 1), value)
|
||||
self.assertEqual((storage[0], storage[33]), (0xA5, 0xA5))
|
||||
self.assertGreater(length, 0)
|
||||
self.assertLessEqual(length, 32)
|
||||
return bytes(storage[1:1 + length])
|
||||
|
||||
def assert_roundtrip(self, text, expected_bits):
|
||||
self.assertRegex(text, JSON_NUMBER, f'Invalid JSON token for {expected_bits:016x}')
|
||||
decoded = json.loads(text)
|
||||
self.assertEqual(double_bits(float(decoded)), expected_bits,
|
||||
f'{expected_bits:016x} became {text!r} then {decoded!r}')
|
||||
|
||||
def write_array(self, values, *, count=None, stride=1, prefix=0, library=None):
|
||||
array = (ctypes.c_double * len(values))(*values) if values else None
|
||||
status = WriteStatus()
|
||||
output = self.root / 'array.json'
|
||||
(library or self.library).test_array_file(os.fsencode(output), array,
|
||||
len(values) if count is None else count, stride, prefix, ctypes.byref(status))
|
||||
self.assertTrue(status.opened)
|
||||
return output.read_bytes(), status
|
||||
|
||||
def test_binary64_boundaries_and_seeded_random_values_roundtrip(self):
|
||||
for bits in finite_patterns():
|
||||
self.assert_roundtrip(self.encode(double_from_bits(bits)), bits)
|
||||
|
||||
def test_64_bit_fallback_roundtrips_the_same_binary64_corpus(self):
|
||||
patterns = finite_patterns()
|
||||
for bits in patterns:
|
||||
self.assert_roundtrip(self.encode(double_from_bits(bits), self.portable_library), bits)
|
||||
values = [double_from_bits(bits) for bits in patterns]
|
||||
ordinary, ordinary_status = self.write_array(values)
|
||||
portable, portable_status = self.write_array(values, library=self.portable_library)
|
||||
self.assertTrue(ordinary_status.written and ordinary_status.closed)
|
||||
self.assertTrue(portable_status.written and portable_status.closed)
|
||||
self.assertEqual(portable, ordinary)
|
||||
|
||||
def test_plain_decimal_is_used_only_when_it_shortens_the_token(self):
|
||||
# Tie cases deliberately keep scientific notation; far exponents must
|
||||
# never be expanded to hundreds of zeroes in the 32-byte destination.
|
||||
cases = ((10.0, b'10'), (12.0, b'12'), (-12.0, b'-12'),
|
||||
(.1, b'0.1'), (-.1, b'-0.1'), (123.45, b'123.45'),
|
||||
(100.0, b'1E2'), (.01, b'1E-2'), (1e100, b'1E100'),
|
||||
(double_from_bits(1), b'5E-324'))
|
||||
for library in (self.library, self.portable_library):
|
||||
for value, expected in cases:
|
||||
with self.subTest(value=value, expected=expected):
|
||||
token = self.encode(value, library)
|
||||
self.assertEqual(token, expected)
|
||||
self.assert_roundtrip(token, double_bits(value))
|
||||
|
||||
def test_zero_sign_and_single_number_file(self):
|
||||
self.assertEqual(self.encode(0.0), b'0')
|
||||
self.assertEqual(self.encode(-0.0), b'-0.0')
|
||||
output = self.root / 'number.json'
|
||||
for bits in (0, SIGN, 1, SIGN | 1, 0x0010000000000000, 0x7fefffffffffffff):
|
||||
status = WriteStatus()
|
||||
self.library.test_number_file(os.fsencode(output), double_from_bits(bits), ctypes.byref(status))
|
||||
self.assertTrue(status.opened and status.written and status.closed)
|
||||
data = output.read_bytes()
|
||||
self.assertEqual(status.end, len(data))
|
||||
self.assert_roundtrip(data, bits)
|
||||
|
||||
def test_array_crosses_block_boundaries_without_changing_offsets(self):
|
||||
cases = [([], 2), ([0.0] * 32767, BUFFER_BYTES - 1),
|
||||
([-0.0] + [0.0] * 32765, BUFFER_BYTES),
|
||||
([0.0] * 32768, BUFFER_BYTES + 1),
|
||||
([double_from_bits(bits) for bits in finite_patterns(0)[::3]], None)]
|
||||
for values, expected_length in cases:
|
||||
for prefix in (0, 37, BUFFER_BYTES - 1):
|
||||
with self.subTest(values=len(values), expected_length=expected_length, prefix=prefix):
|
||||
data, status = self.write_array(values, prefix=prefix)
|
||||
self.assertTrue(status.written and status.closed)
|
||||
self.assertEqual(status.start, prefix)
|
||||
self.assertEqual(data[:prefix], b'p' * prefix)
|
||||
self.assertEqual(data[status.end:], b'TAIL')
|
||||
self.assertEqual(status.final_position, len(data))
|
||||
token = data[status.start:status.end]
|
||||
if expected_length is not None:
|
||||
self.assertEqual(len(token), expected_length)
|
||||
decoded = json.loads(token)
|
||||
self.assertEqual([double_bits(float(v)) for v in decoded], [double_bits(v) for v in values])
|
||||
|
||||
def test_strided_array_keeps_selected_column_order(self):
|
||||
selected = [double_from_bits(bits) for bits in finite_patterns(0)[::13]]
|
||||
for stride in (1, 3, 17):
|
||||
with self.subTest(stride=stride):
|
||||
values = [math.nan] * (len(selected) * stride)
|
||||
values[::stride] = selected
|
||||
data, status = self.write_array(values, count=len(selected), stride=stride)
|
||||
self.assertTrue(status.written and status.closed)
|
||||
decoded = json.loads(data[:status.end])
|
||||
self.assertEqual([double_bits(float(v)) for v in decoded], [double_bits(v) for v in selected])
|
||||
|
||||
def test_invalid_file_pointer_and_array_bounds_fail_before_access(self):
|
||||
value = (ctypes.c_double * 1)(.1)
|
||||
self.assertEqual(self.library.native_json_write_number(None, .1), 0)
|
||||
self.assertEqual(self.library.native_json_write_array(None, value, 1, 1), 0)
|
||||
for values, count, stride in (([], 1, 1), ([.1], 2, 0),
|
||||
([.1], 2, ctypes.c_size_t(-1).value)):
|
||||
with self.subTest(count=count, stride=stride):
|
||||
data, status = self.write_array(values, count=count, stride=stride)
|
||||
self.assertTrue(status.opened and status.closed)
|
||||
self.assertFalse(status.written)
|
||||
self.assertEqual(data, b'')
|
||||
# A single sample never advances its pointer; an enormous stride is safe.
|
||||
data, status = self.write_array([.1], count=1, stride=ctypes.c_size_t(-1).value)
|
||||
self.assertTrue(status.written and status.closed)
|
||||
self.assertEqual(json.loads(data[:status.end]), [.1])
|
||||
|
||||
def test_nonfinite_numbers_fail_instead_of_emitting_invalid_json(self):
|
||||
output = self.root / 'nonfinite.json'
|
||||
for bits in (0x7ff0000000000000, 0xfff0000000000000,
|
||||
0x7ff8000000000000, 0xfff8000000000001, 0x7ff0000000000001):
|
||||
value = double_from_bits(bits)
|
||||
with self.subTest(bits=f'{bits:016x}'):
|
||||
token = ctypes.create_string_buffer(32)
|
||||
self.assertEqual(self.library.native_json_format_double(token, value), 0)
|
||||
status = WriteStatus()
|
||||
self.library.test_number_file(os.fsencode(output), value, ctypes.byref(status))
|
||||
self.assertTrue(status.opened and status.closed)
|
||||
self.assertFalse(status.written)
|
||||
self.assertEqual(output.read_bytes(), b'')
|
||||
for finite_prefix in ([], [0.0] * (BUFFER_BYTES + 1)):
|
||||
data, status = self.write_array(finite_prefix + [value])
|
||||
self.assertFalse(status.written)
|
||||
self.assertTrue(status.closed)
|
||||
self.assertNotIn(b'NaN', data)
|
||||
self.assertNotIn(b'Infinity', data)
|
||||
|
||||
def test_zero_and_short_writes_are_reported_without_stdio_error_flag(self):
|
||||
# The injected fwrite can return short without setting FILE's error bit;
|
||||
# relying only on ferror/fclose would incorrectly report success.
|
||||
output = self.root / 'fault-number.json'
|
||||
try:
|
||||
for mode in (1, 2):
|
||||
with self.subTest(mode=mode):
|
||||
self.fault_mode.value = mode
|
||||
self.fault_calls.value = 0
|
||||
status = WriteStatus()
|
||||
self.fault_library.test_number_file(os.fsencode(output), .1, ctypes.byref(status))
|
||||
self.assertTrue(status.opened and status.closed)
|
||||
self.assertFalse(status.written)
|
||||
self.assertGreater(self.fault_calls.value, 0)
|
||||
self.fault_calls.value = 0
|
||||
_, status = self.write_array([1.0, 2.0], library=self.fault_library)
|
||||
self.assertFalse(status.written)
|
||||
self.assertTrue(status.closed)
|
||||
self.fault_mode.value = 3
|
||||
self.fault_calls.value = 0
|
||||
data, status = self.write_array([0.0] * (BUFFER_BYTES * 2), library=self.fault_library)
|
||||
self.assertFalse(status.written)
|
||||
self.assertTrue(status.closed)
|
||||
self.assertGreaterEqual(self.fault_calls.value, 2)
|
||||
self.assertTrue(data, 'The initial successful block must survive a later write failure')
|
||||
finally:
|
||||
self.fault_mode.value = 0
|
||||
self.fault_calls.value = 0
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix' and Path('/dev/full').exists(), '/dev/full is needed for a real delayed I/O failure')
|
||||
def test_caller_must_check_delayed_fclose_failure(self):
|
||||
written, closed = ctypes.c_int(), ctypes.c_int()
|
||||
self.assertEqual(self.library.test_delayed_close_failure(b'/dev/full', ctypes.byref(written), ctypes.byref(closed)), 1)
|
||||
self.assertEqual(written.value, 1, 'A small buffered token should not force FILE fflush')
|
||||
self.assertEqual(closed.value, 0, 'FILE close must expose the delayed device error')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""The HTTP fast path must preserve real native values and task semantics."""
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import asyncio
|
||||
from app.main import app, _register_simulation_task, _request_simulation_task_cancel, simulation_event_stream
|
||||
from app.simulation.backends import simulation_config
|
||||
from app.simulation.native_codegen.build import build_native
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from app.simulation.native_codegen.runner import execute_native
|
||||
from app.simulation.native_codegen.transport import NativeSeriesJson, read_indexed_result, serialize_result_parts
|
||||
from app.main import compile_system_xml_network
|
||||
|
||||
|
||||
class AsgiClient:
|
||||
"""Exercise real routing/response bodies without an optional HTTP client dependency."""
|
||||
def __init__(self, application): self.application = application
|
||||
def post(self, path, *, content=b'', headers=None): return self.request('POST', path, content, headers)
|
||||
def get(self, path): return self.request('GET', path, b'', None)
|
||||
def request(self, method, path, content, headers):
|
||||
async def run():
|
||||
messages = []
|
||||
scope = {'type':'http', 'asgi':{'version':'3.0','spec_version':'2.4'},
|
||||
'http_version':'1.1','method':method,'scheme':'http','path':path,
|
||||
'raw_path':path.encode(),'query_string':b'', 'root_path':'',
|
||||
'headers':[(k.lower().encode(),v.encode()) for k,v in (headers or {}).items()],
|
||||
'server':('testserver',80),'client':('127.0.0.1',1234)}
|
||||
async def receive(): return {'type':'http.request','body':content,'more_body':False}
|
||||
async def send(message): messages.append(message)
|
||||
await self.application(scope,receive,send)
|
||||
status = next(m['status'] for m in messages if m['type']=='http.response.start')
|
||||
body = b''.join(m.get('body',b'') for m in messages if m['type']=='http.response.body')
|
||||
return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body))
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
class NativeResultTransportTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix='test-native-transport-')
|
||||
cls.root = Path(cls.temp.name)
|
||||
cls.xml = Path('tests/fixtures/native-skill-test.xml').read_bytes().replace(b'tStop="10"', b'tStop="0.1"')
|
||||
source = cls.root/'input.xml'; source.write_bytes(cls.xml)
|
||||
_, document = load_input(source)
|
||||
cls.config = simulation_config(document.simulation)
|
||||
cls.build = build_native(compile_native_program(compile_system_xml_network(document)))
|
||||
cls.normal = execute_native(cls.build, cls.config, .001, run_dir=cls.root/'ordinary')
|
||||
cls.indexed = execute_native(cls.build, cls.config, .001, run_dir=cls.root/'indexed', raw_series=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temp.cleanup()
|
||||
|
||||
def test_real_native_series_are_equal_without_large_python_parse(self):
|
||||
self.assertIsInstance(self.indexed['series'], NativeSeriesJson)
|
||||
self.assertEqual(self.indexed['series'].materialize(), self.normal['series'])
|
||||
self.assertEqual(self.indexed['series'].sample_count, len(self.normal['series']['time']))
|
||||
for key in ('final', 'finalState', 'nfev', 'acceptedSteps', 'rejectedSteps'):
|
||||
self.assertEqual(self.indexed[key], self.normal[key])
|
||||
self.assertGreater(len(self.indexed['series'].data), 100000)
|
||||
original = json.loads
|
||||
sizes = []
|
||||
def small_only(value):
|
||||
sizes.append(len(value))
|
||||
self.assertLess(len(value), 50000, 'Raw series was decoded through Python')
|
||||
return original(value)
|
||||
with patch('app.simulation.native_codegen.transport.json', SimpleNamespace(loads=small_only)):
|
||||
payload = read_indexed_result(self.root/'indexed/result.json', self.root/'indexed/result-index.json')
|
||||
self.assertEqual(len(sizes), 2) # index and small metadata only
|
||||
self.assertEqual(payload['series'].data, self.indexed['series'].data)
|
||||
|
||||
def test_real_http_stream_and_retained_task_get_keep_same_schema(self):
|
||||
client = AsgiClient(app)
|
||||
ident = 'transport-'+uuid4().hex
|
||||
response = client.post('/api/system-xml/simulate-stream', content=self.xml, headers={'X-Simulation-Id':ident})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
events = [json.loads(line) for line in response.content.splitlines()]
|
||||
result = next(event['result'] for event in events if event['event'] == 'result')
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['simulatedUntil'], .1)
|
||||
self.assertEqual(result['diagnostics']['sampleCount'], len(result['series']['time']))
|
||||
# The bytes survive deletion of the worker directory and repeated task reads.
|
||||
for _ in range(2):
|
||||
retained = client.get('/api/system-xml/simulations/'+ident)
|
||||
self.assertEqual(retained.status_code, 200)
|
||||
self.assertEqual(retained.json()['result'], result)
|
||||
synchronous = client.post('/api/system-xml/simulate', content=self.xml).json()
|
||||
for key in ('series', 'final', 'variables', 'model', 'simulation'):
|
||||
self.assertEqual(synchronous[key], result[key])
|
||||
|
||||
def test_cancelled_raw_stream_keeps_partial_result_and_public_status(self):
|
||||
for reason, status in [('user', 'stopped'), ('stalled', 'stalled')]:
|
||||
task = _register_simulation_task('transport-'+uuid4().hex)
|
||||
_request_simulation_task_cancel(task, reason)
|
||||
body = b''.join(part.encode() if isinstance(part, str) else part
|
||||
for part in simulation_event_stream(self.xml, task=task, raw_series=True))
|
||||
result = next(event['result'] for event in map(json.loads, body.splitlines()) if event['event']=='result')
|
||||
self.assertEqual(result['status'], status)
|
||||
self.assertTrue(result['partial'])
|
||||
self.assertLess(result['simulatedUntil'], .1)
|
||||
self.assertEqual(result['series']['time'][-1], result['simulatedUntil'])
|
||||
self.assertEqual(AsgiClient(app).get('/api/system-xml/simulations/'+task.simulation_id).json()['result'], result)
|
||||
|
||||
def test_index_corruption_or_truncated_output_is_rejected(self):
|
||||
directory = self.root/'corrupt'; directory.mkdir(exist_ok=True)
|
||||
data = (self.root/'indexed/result.json').read_bytes()
|
||||
original = json.loads((self.root/'indexed/result-index.json').read_bytes())
|
||||
output = directory/'result.json'; index = directory/'index.json'
|
||||
output.write_bytes(data)
|
||||
for change in ({'version':2}, {'version':True}, {'version':1.0}, {'seriesStart':True}, {'seriesStart':-1}, {'seriesEnd':len(data)+1},
|
||||
{'resultBytes':len(data)-1}, {'sampleCount':-1}, {'seriesStart':original['seriesStart']+1}):
|
||||
with self.subTest(change=change):
|
||||
index.write_text(json.dumps(original | change))
|
||||
with self.assertRaises(ValueError): read_indexed_result(output,index)
|
||||
index.write_text(json.dumps(original)); output.write_bytes(data[:-2])
|
||||
with self.assertRaises(ValueError): read_indexed_result(output,index)
|
||||
|
||||
def test_json_framing_and_escaping_cannot_confuse_raw_series(self):
|
||||
values = {'time':[0,1], 'odd\\"},"final":{\n温度':[1e-300,-0.0]}
|
||||
raw = NativeSeriesJson(json.dumps(values,ensure_ascii=False).encode(),2)
|
||||
for payload in ({'result':{'series':raw}},
|
||||
{'event':'result','message':'\n"series":{},"result":null',
|
||||
'result':{'series':raw,'label':'\\"雪\n','success':True}}):
|
||||
encoded = b''.join(serialize_result_parts(payload))
|
||||
expected = dict(payload, result=dict(payload['result'], series=values))
|
||||
self.assertEqual(json.loads(encoded), expected)
|
||||
self.assertEqual(json.loads(b''.join(serialize_result_parts({'event':'progress'}))), {'event':'progress'})
|
||||
|
||||
def test_solve_only_raw_series_is_empty_object(self):
|
||||
result = execute_native(self.build, replace(self.config,t_stop=.001),.001,
|
||||
run_dir=self.root/'solve-only', raw_series=True,record_samples=False)
|
||||
self.assertEqual(result['series'].data,b'{}')
|
||||
self.assertEqual(result['series'].sample_count,0)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in new issue
Block a user