增加可选性能埋点并完成物性效率评估

This commit is contained in:
ljz committed 2026-08-16 17:46:04 +08:00
1 parent 4e0b9fd8cc
commit 57b459bc72
21 files changed
+2029 -179

No files matched your search

+29 -6
View File
@@ -21,6 +21,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.simulation.performance import performance_span, profile_phase, profile_run
from app.system_xml import ( from app.system_xml import (
SystemXmlDocument, SystemXmlDocument,
SystemXmlValidationReport, SystemXmlValidationReport,
@@ -666,6 +667,26 @@ def run_system_xml_simulation(
xml_bytes: bytes, xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None, progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None, cancel_check: Callable[[], bool] | None = None,
) -> dict[str, object]:
with profile_run() as trace:
result = _run_system_xml_simulation_profiled(
xml_bytes,
progress_callback,
cancel_check,
)
performance = trace.snapshot()
if performance.get("mode") != "off":
diagnostics = dict(result.get("diagnostics", {}))
diagnostics["performance"] = performance
result["diagnostics"] = diagnostics
return result
def _run_system_xml_simulation_profiled(
xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
from app.simulation.solvers.algebraic import AlgebraicSolveError from app.simulation.solvers.algebraic import AlgebraicSolveError
from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
@@ -792,12 +813,13 @@ def run_system_xml_simulation(
}, },
) from exc ) from exc
return { with performance_span("simulation.response_assembly"):
"validation": report.as_dict(), return {
"simulation": document.as_model_data()["simulation"], "validation": report.as_dict(),
"model": network.as_interface_dict(), "simulation": document.as_model_data()["simulation"],
**result.as_dict(), "model": network.as_interface_dict(),
} **result.as_dict(),
}
def simulation_event_stream( def simulation_event_stream(
@@ -1200,6 +1222,7 @@ def compile_reactflow_network(
) )
@profile_phase("simulation.network_compilation")
def compile_system_xml_network( def compile_system_xml_network(
document: SystemXmlDocument, document: SystemXmlDocument,
*, *,
+18
View File
@@ -42,6 +42,8 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson - `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson
- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义 - `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义
- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程 - `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程
- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点
- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 - `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 - `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔 - `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
@@ -58,6 +60,22 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口 - `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试 - `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
## 可选性能诊断
`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard`
只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也
明显更高。最终优化收益必须在 `off` 下复测。
```powershell
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
--mode audit --warmups 1 --runs 3 `
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
--output app/data/performance-evaluations/helium-step.json
```
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。
## 当前阶段进度 ## 当前阶段进度
这一阶段原先有 4 件重点工作,现在的状态如下: 这一阶段原先有 4 件重点工作,现在的状态如下:
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import os
import platform
import statistics
import sys
from datetime import UTC, datetime
from math import ceil
from pathlib import Path
from time import perf_counter_ns, process_time_ns
from typing import Any
def _named_value(value: str, *, option: str) -> tuple[str, str]:
name, separator, target = value.partition("=")
if not separator or not name.strip() or not target.strip():
raise ValueError(
f"{option} must use NAME=VALUE syntax, received {value!r}."
)
return name.strip(), target.strip()
def _percentile(values: list[float], percentile: float) -> float:
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1))
return ordered[index]
def _duration_summary(values: list[float]) -> dict[str, object]:
return {
"samplesMs": values,
"minimumMs": min(values),
"medianMs": statistics.median(values),
"p95Ms": _percentile(values, 0.95),
"maximumMs": max(values),
}
def _load_factory_xml(specification: str) -> bytes:
module_name, separator, member_name = specification.partition(":")
if not separator or not module_name or not member_name:
raise ValueError(
"Factory specifications must use module.path:callable syntax."
)
factory = getattr(importlib.import_module(module_name), member_name)
value = factory()
if isinstance(value, bytes):
return value
if isinstance(value, str):
return value.encode("utf-8")
from app.main import build_reactflow_system_xml
return build_reactflow_system_xml(value)
def _clear_property_caches() -> None:
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
for method_name in (
"temperature_from_pressure_enthalpy",
"properties_from_mU",
):
method = getattr(AmesimHeliumPengRobinsonMedium, method_name)
cache_clear = getattr(method, "cache_clear", None)
if cache_clear is not None:
cache_clear()
def _serialize_result_event(result: dict[str, object]) -> bytes:
"""Render the final NDJSON payload shape used by the streaming endpoint."""
status = str(result.get("status", "completed"))
event = {
"event": "result",
"progress": 100 if status == "completed" else 0,
"phase": status,
"message": "仿真完成" if status == "completed" else "仿真任务结束",
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
return (
json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
).encode("utf-8")
def _run_case(
name: str,
xml_bytes: bytes,
*,
warmups: int,
runs: int,
cancellable_path: bool,
clear_property_cache: bool,
allow_failures: bool,
) -> dict[str, object]:
from app.main import run_system_xml_simulation
cancel_check = (lambda: False) if cancellable_path else None
for _ in range(warmups):
if clear_property_cache:
_clear_property_caches()
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}")
wall_samples_ms: list[float] = []
cpu_samples_ms: list[float] = []
serialization_samples_ms: list[float] = []
serialized_sizes: list[int] = []
profiles: list[dict[str, object]] = []
final_result: dict[str, object] | None = None
for _ in range(runs):
if clear_property_cache:
_clear_property_caches()
wall_start = perf_counter_ns()
cpu_start = process_time_ns()
result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check)
cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0)
wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0)
if not bool(result.get("success")) and not allow_failures:
raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}")
diagnostics = result.get("diagnostics")
if isinstance(diagnostics, dict):
performance = diagnostics.get("performance")
if isinstance(performance, dict):
profiles.append(performance)
serialization_start = perf_counter_ns()
serialized_event = _serialize_result_event(result)
serialization_samples_ms.append(
(perf_counter_ns() - serialization_start) / 1_000_000.0
)
serialized_sizes.append(len(serialized_event))
final_result = result
assert final_result is not None
return {
"name": name,
"success": bool(final_result.get("success")),
"message": final_result.get("message"),
"inputBytes": len(xml_bytes),
"inputSha256": hashlib.sha256(xml_bytes).hexdigest(),
"status": final_result.get("status"),
"simulatedUntil": final_result.get("simulatedUntil"),
"requestedStopTime": final_result.get("requestedStopTime"),
"wall": _duration_summary(wall_samples_ms),
"cpu": _duration_summary(cpu_samples_ms),
"resultSerialization": _duration_summary(serialization_samples_ms),
"resultEventBytes": serialized_sizes,
"performanceRuns": profiles,
}
def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Benchmark the real System XML simulation path with optional profiling."
)
parser.add_argument(
"--mode",
choices=("off", "standard", "audit"),
default="audit",
help="Instrumentation depth selected before importing the simulation modules.",
)
parser.add_argument("--warmups", type=int, default=1)
parser.add_argument("--runs", type=int, default=5)
parser.add_argument(
"--xml",
action="append",
default=[],
metavar="NAME=PATH",
help="Add an XML file benchmark case.",
)
parser.add_argument(
"--factory",
action="append",
default=[],
metavar="NAME=MODULE:CALLABLE",
help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.",
)
parser.add_argument(
"--direct-path",
action="store_true",
help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.",
)
parser.add_argument(
"--cold-property-cache",
action="store_true",
help="Clear the two helium property LRU caches before every warmup and measured run.",
)
parser.add_argument(
"--allow-failures",
action="store_true",
help="Record failed simulation runs instead of aborting the benchmark.",
)
parser.add_argument("--output", type=Path)
arguments = parser.parse_args(argv)
if arguments.warmups < 0:
parser.error("--warmups must not be negative.")
if arguments.runs <= 0:
parser.error("--runs must be positive.")
if not arguments.xml and not arguments.factory:
parser.error("At least one --xml or --factory case is required.")
return arguments
def main(argv: list[str] | None = None) -> int:
arguments = _parse_arguments(argv)
os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode
cases: list[tuple[str, bytes]] = []
for raw_case in arguments.xml:
name, raw_path = _named_value(raw_case, option="--xml")
cases.append((name, Path(raw_path).read_bytes()))
for raw_case in arguments.factory:
name, specification = _named_value(raw_case, option="--factory")
cases.append((name, _load_factory_xml(specification)))
report: dict[str, Any] = {
"generatedAt": datetime.now(UTC).isoformat(),
"profileMode": arguments.mode,
"cancellableSolverPath": not arguments.direct_path,
"coldPropertyCache": bool(arguments.cold_property_cache),
"allowFailures": bool(arguments.allow_failures),
"warmups": arguments.warmups,
"runs": arguments.runs,
"runtime": {
"python": sys.version,
"platform": platform.platform(),
"processor": platform.processor(),
},
"cases": [
_run_case(
name,
xml_bytes,
warmups=arguments.warmups,
runs=arguments.runs,
cancellable_path=not arguments.direct_path,
clear_property_cache=arguments.cold_property_cache,
allow_failures=arguments.allow_failures,
)
for name, xml_bytes in cases
],
}
text = json.dumps(report, ensure_ascii=False, indent=2)
if arguments.output is not None:
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(text + "\n", encoding="utf-8")
print(f"Performance report written to {arguments.output.resolve()}")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -12,6 +12,7 @@ from app.simulation.core.medium import (
ThermodynamicProperties, ThermodynamicProperties,
) )
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.performance import profile_property, record_property_iterations
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -69,6 +70,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
del T del T
return self.cv return self.cv
@profile_property("density")
def density(self, p: float, T: float) -> float: def density(self, p: float, T: float) -> float:
return self.fluid.density(p, T) return self.fluid.density(p, T)
@@ -134,6 +136,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
) )
return factor, exponent return factor, exponent
@profile_property("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor( def isentropic_density_pressure_factor(
self, self,
p: float, p: float,
@@ -166,12 +169,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
raise ValueError("Volume must stay positive.") raise ValueError("Volume must stay positive.")
return self.fluid.pressure_from_density(T, m / V) return self.fluid.pressure_from_density(T, m / V)
@profile_property("specific_internal_energy")
def specific_internal_energy(self, T: float) -> float: def specific_internal_energy(self, T: float) -> float:
return self.R_gas * ( return self.R_gas * (
(self.nasa_cp_over_R - 1.0) * T (self.nasa_cp_over_R - 1.0) * T
+ self.nasa_enthalpy_constant_K + self.nasa_enthalpy_constant_K
) )
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
density = self.density(p, T) density = self.density(p, T)
return ( return (
@@ -179,12 +184,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
+ self.fluid.residual_specific_internal_energy_at_density(T, density) + self.fluid.residual_specific_internal_energy_at_density(T, density)
) )
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float: def specific_enthalpy(self, T: float) -> float:
return self.R_gas * ( return self.R_gas * (
self.nasa_cp_over_R * T self.nasa_cp_over_R * T
+ self.nasa_enthalpy_constant_K + self.nasa_enthalpy_constant_K
) )
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T) return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T)
@@ -198,6 +205,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
h / self.R_gas - self.nasa_enthalpy_constant_K h / self.R_gas - self.nasa_enthalpy_constant_K
) / self.nasa_cp_over_R ) / self.nasa_cp_over_R
@profile_property("temperature_from_pressure_enthalpy", track_cache=True)
@lru_cache(maxsize=8192) @lru_cache(maxsize=8192)
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
temperature = max(self.temperature_from_enthalpy(h), 2.2) temperature = max(self.temperature_from_enthalpy(h), 2.2)
@@ -211,8 +219,18 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
temperature, temperature,
1.0, 1.0,
): ):
record_property_iterations(
"temperature_from_pressure_enthalpy",
_iteration + 1,
True,
)
return next_temperature return next_temperature
temperature = next_temperature temperature = next_temperature
record_property_iterations(
"temperature_from_pressure_enthalpy",
16,
False,
)
return temperature return temperature
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
@@ -222,6 +240,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
) )
return self.temperature_from_internal_energy(U / m) return self.temperature_from_internal_energy(U / m)
@profile_property("properties_from_mU", track_cache=True)
@lru_cache(maxsize=8192) @lru_cache(maxsize=8192)
def properties_from_mU( def properties_from_mU(
self, self,
@@ -249,6 +268,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
self.temperature_from_internal_energy(target_internal_energy), self.temperature_from_internal_energy(target_internal_energy),
2.2, 2.2,
) )
converged = False
for _iteration in range(16): for _iteration in range(16):
residual_internal_energy = ( residual_internal_energy = (
self.fluid.residual_specific_internal_energy_at_density( self.fluid.residual_specific_internal_energy_at_density(
@@ -267,8 +287,14 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
1.0, 1.0,
): ):
temperature = next_temperature temperature = next_temperature
converged = True
break break
temperature = next_temperature temperature = next_temperature
record_property_iterations(
"properties_from_mU",
_iteration + 1,
converged,
)
pressure = self.fluid.pressure_from_density(temperature, density) pressure = self.fluid.pressure_from_density(temperature, density)
return ThermodynamicProperties( return ThermodynamicProperties(
p=pressure, p=pressure,
+10
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from typing import Protocol from typing import Protocol
from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.performance import profile_property
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -108,9 +109,11 @@ class IdealGasMedium:
def cv_at_temperature(self, T: float) -> float: def cv_at_temperature(self, T: float) -> float:
return self.cp_at_temperature(T) - self.R_gas return self.cp_at_temperature(T) - self.R_gas
@profile_property("density")
def density(self, p: float, T: float) -> float: def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T) return p / (self.R_gas * T)
@profile_property("isentropic_density_pressure_factor")
def isentropic_density_pressure_factor( def isentropic_density_pressure_factor(
self, self,
p: float, p: float,
@@ -123,6 +126,7 @@ class IdealGasMedium:
cv = self.cv_at_temperature(T) cv = self.cv_at_temperature(T)
return cv / cp return cv / cp
@profile_property("dynamic_viscosity")
def dynamic_viscosity(self, T: float) -> float: def dynamic_viscosity(self, T: float) -> float:
"""Return dynamic viscosity using the default air Sutherland law.""" """Return dynamic viscosity using the default air Sutherland law."""
@@ -135,6 +139,7 @@ class IdealGasMedium:
/ (T + self.sutherland_constant) / (T + self.sutherland_constant)
) )
@profile_property("specific_internal_energy")
def specific_internal_energy(self, T: float) -> float: def specific_internal_energy(self, T: float) -> float:
delta_T = T - self.T_ref delta_T = T - self.T_ref
return ( return (
@@ -143,10 +148,12 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T + 0.5 * self.cp_slope * delta_T * delta_T
) )
@profile_property("specific_internal_energy_at_pressure")
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
del p del p
return self.specific_internal_energy(T) return self.specific_internal_energy(T)
@profile_property("specific_enthalpy")
def specific_enthalpy(self, T: float) -> float: def specific_enthalpy(self, T: float) -> float:
delta_T = T - self.T_ref delta_T = T - self.T_ref
return ( return (
@@ -155,6 +162,7 @@ class IdealGasMedium:
+ 0.5 * self.cp_slope * delta_T * delta_T + 0.5 * self.cp_slope * delta_T * delta_T
) )
@profile_property("specific_enthalpy_at_pressure")
def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: def specific_enthalpy_at_pressure(self, p: float, T: float) -> float:
del p del p
return self.specific_enthalpy(T) return self.specific_enthalpy(T)
@@ -191,6 +199,7 @@ class IdealGasMedium:
delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root
return self.T_ref + delta_T return self.T_ref + delta_T
@profile_property("temperature_from_pressure_enthalpy")
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
del p del p
return self.temperature_from_enthalpy(h) return self.temperature_from_enthalpy(h)
@@ -207,6 +216,7 @@ class IdealGasMedium:
raise ValueError("Volume must stay positive.") raise ValueError("Volume must stay positive.")
return m * self.R_gas * T / V return m * self.R_gas * T / V
@profile_property("properties_from_mU")
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties: def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
T = self.temperature_from_mass_internal_energy(m, U) T = self.temperature_from_mass_internal_energy(m, U)
p = self.pressure(m, T, V) p = self.pressure(m, T, V)
+53
View File
@@ -5,6 +5,8 @@ from app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt from math import acos, cos, isfinite, log, pi, sqrt
from app.simulation.performance import profile_property
UNIVERSAL_GAS_CONSTANT = 8.31446261815324 UNIVERSAL_GAS_CONSTANT = 8.31446261815324
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the # Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
# commonly printed Peng-Robinson constants 0.45724 and 0.07780. # commonly printed Peng-Robinson constants 0.45724 and 0.07780.
@@ -97,6 +99,11 @@ class PengRobinsonFluid:
) -> float: ) -> float:
return self.a_parameter * self.alpha_temperature_second_derivative(temperature) return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
@profile_property(
"pressure_from_molar_volume",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float: def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature) self._validate_temperature(temperature)
if molar_volume <= self.b_parameter: if molar_volume <= self.b_parameter:
@@ -107,11 +114,21 @@ class PengRobinsonFluid:
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b)) attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive return repulsive - attractive
@profile_property(
"pressure_from_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_density(self, temperature: float, density: float) -> float: def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0: if density <= 0.0:
raise ValueError("Density must be positive.") raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density) return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
@profile_property(
"pressure_temperature_derivative_at_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_temperature_derivative_at_density( def pressure_temperature_derivative_at_density(
self, self,
temperature: float, temperature: float,
@@ -132,6 +149,11 @@ class PengRobinsonFluid:
- self.attractive_parameter_temperature_derivative(temperature) / denominator - self.attractive_parameter_temperature_derivative(temperature) / denominator
) )
@profile_property(
"pressure_density_derivative_at_temperature",
layer="kernel",
minimum_mode="audit",
)
def pressure_density_derivative_at_temperature( def pressure_density_derivative_at_temperature(
self, self,
temperature: float, temperature: float,
@@ -165,6 +187,11 @@ class PengRobinsonFluid:
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature) B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B return A, B
@profile_property(
"compressibility_roots",
layer="kernel",
minimum_mode="audit",
)
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]: def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature) A, B = self.reduced_parameters(pressure, temperature)
coefficients = ( coefficients = (
@@ -178,6 +205,11 @@ class PengRobinsonFluid:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.") raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots return physical_roots
@profile_property(
"compressibility_factor",
layer="kernel",
minimum_mode="audit",
)
def compressibility_factor( def compressibility_factor(
self, self,
pressure: float, pressure: float,
@@ -193,6 +225,11 @@ class PengRobinsonFluid:
return roots[-1] return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}") raise ValueError(f"Unsupported phase selector: {phase!r}")
@profile_property(
"molar_volume",
layer="kernel",
minimum_mode="audit",
)
def molar_volume( def molar_volume(
self, self,
pressure: float, pressure: float,
@@ -202,6 +239,7 @@ class PengRobinsonFluid:
z = self.compressibility_factor(pressure, temperature, phase=phase) z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
@profile_property("density", layer="kernel", minimum_mode="audit")
def density( def density(
self, self,
pressure: float, pressure: float,
@@ -210,6 +248,11 @@ class PengRobinsonFluid:
) -> float: ) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase) return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
@profile_property(
"residual_specific_enthalpy",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_enthalpy( def residual_specific_enthalpy(
self, self,
pressure: float, pressure: float,
@@ -239,6 +282,11 @@ class PengRobinsonFluid:
) )
return residual_molar_enthalpy / self.molar_mass return residual_molar_enthalpy / self.molar_mass
@profile_property(
"residual_specific_internal_energy_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_internal_energy_at_density( def residual_specific_internal_energy_at_density(
self, self,
temperature: float, temperature: float,
@@ -268,6 +316,11 @@ class PengRobinsonFluid:
) * log(log_argument) / (2.0 * sqrt(2.0) * b) ) * log(log_argument) / (2.0 * sqrt(2.0) * b)
return residual_molar_internal_energy / self.molar_mass return residual_molar_internal_energy / self.molar_mass
@profile_property(
"residual_isochoric_heat_capacity_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_isochoric_heat_capacity_at_density( def residual_isochoric_heat_capacity_at_density(
self, self,
temperature: float, temperature: float,
+665
View File
@@ -0,0 +1,665 @@
"""Low-overhead, run-local performance instrumentation for simulations.
The profiling mode is intentionally read once when this module is imported.
``standard`` records low-frequency pipeline stages, while ``audit`` also wraps
hot RHS/property operations and computes exact-input reuse metrics. With
profiling disabled, decorators return the original callable while classes are
being defined, so ordinary simulation calls do not pass through a wrapper.
"""
from __future__ import annotations
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from functools import wraps
import inspect
import os
import struct
from time import perf_counter_ns
from typing import Any, Literal, ParamSpec, TypeVar, cast
ProfileMode = Literal["off", "standard", "audit"]
_P = ParamSpec("_P")
_R = TypeVar("_R")
_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2}
def _read_startup_mode() -> ProfileMode:
raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower()
aliases: dict[str, ProfileMode] = {
"": "off",
"0": "off",
"false": "off",
"no": "off",
"off": "off",
"1": "standard",
"true": "standard",
"yes": "standard",
"on": "standard",
"standard": "standard",
"audit": "audit",
}
try:
return aliases[raw_mode]
except KeyError as exc:
raise ValueError(
"SIMULATIONAPP_PROFILE must be one of: off, standard, audit."
) from exc
PROFILE_MODE: ProfileMode = _read_startup_mode()
def _minimum_mode(value: str) -> ProfileMode:
normalized = value.strip().lower()
if normalized not in _MODE_RANK:
raise ValueError("minimum_mode must be one of: off, standard, audit.")
return cast(ProfileMode, normalized)
def _mode_enabled(minimum_mode: ProfileMode) -> bool:
# ``off`` is an unconditional zero-wrapper mode, even if a caller passes
# ``minimum_mode="off"`` by mistake.
return (
PROFILE_MODE != "off"
and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode]
)
@dataclass
class _TimingStats:
calls: int = 0
inclusive_ns: int = 0
self_ns: int = 0
max_ns: int = 0
errors: int = 0
def record(self, inclusive_ns: int, self_ns: int, error: bool) -> None:
self.calls += 1
self.inclusive_ns += inclusive_ns
self.self_ns += self_ns
self.max_ns = max(self.max_ns, inclusive_ns)
if error:
self.errors += 1
def snapshot(self) -> dict[str, int]:
return {
"calls": self.calls,
"inclusiveNs": self.inclusive_ns,
"selfNs": self.self_ns,
"maxNs": self.max_ns,
"errors": self.errors,
}
@dataclass
class _PropertyStats(_TimingStats):
operation: str = ""
layer: str = "semantic"
medium: str = "unknown"
exact_input_unique: int = 0
exact_input_repeats: int = 0
iteration_calls: int = 0
iteration_total: int = 0
iteration_max: int = 0
iteration_converged: int = 0
iteration_nonconverged: int = 0
cache_lookups: int = 0
cache_hits: int = 0
cache_misses: int = 0
def snapshot(self, *, audit: bool) -> dict[str, object]:
result: dict[str, object] = super().snapshot()
result.update(
{
"operation": self.operation,
"layer": self.layer,
"medium": self.medium,
"cacheLookups": self.cache_lookups,
"cacheHits": self.cache_hits,
"cacheMisses": self.cache_misses,
}
)
if audit:
result.update(
{
"exactInputUnique": self.exact_input_unique,
"exactInputRepeats": self.exact_input_repeats,
"iterationCalls": self.iteration_calls,
"iterationTotal": self.iteration_total,
"iterationMax": self.iteration_max,
"iterationConverged": self.iteration_converged,
"iterationNonconverged": self.iteration_nonconverged,
}
)
return result
@dataclass
class _ActiveSpan:
trace: PerformanceTrace
name: str
started_ns: int
property_key: str | None = None
property_operation: str | None = None
property_outermost: bool = False
child_ns: int = 0
@dataclass
class PerformanceTrace:
"""Mutable counters owned by exactly one :func:`profile_run` context."""
mode: ProfileMode
_phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False)
_properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False)
_property_outermost_ns: int = field(default=0, repr=False)
@property
def enabled(self) -> bool:
return self.mode != "off"
def _property_stats(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
) -> _PropertyStats:
stats = self._properties.get(key)
if stats is None:
stats = _PropertyStats(
operation=operation,
layer=layer,
medium=medium,
)
self._properties[key] = stats
return stats
def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None:
self_ns = max(0, elapsed_ns - frame.child_ns)
if frame.property_key is None:
stats = self._phases.setdefault(frame.name, _TimingStats())
else:
layer, medium, operation = frame.property_key.split("|", 2)
stats = self._property_stats(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
)
if frame.property_outermost:
self._property_outermost_ns += elapsed_ns
stats.record(elapsed_ns, self_ns, error)
def _record_exact_input(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
fingerprint: object,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
shadow_key = (key, fingerprint)
shadow = _PROPERTY_SHADOW.get()
if shadow is None:
# A trace normally installs its own set in ``profile_run``. Keep
# the ContextVar default immutable so no task can accidentally
# share a process-global shadow set.
shadow = set()
_PROPERTY_SHADOW.set(shadow)
if shadow_key in shadow:
stats.exact_input_repeats += 1
else:
shadow.add(shadow_key)
stats.exact_input_unique += 1
def _record_iterations(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
iterations: int,
converged: bool,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
iteration_count = max(0, int(iterations))
stats.iteration_calls += 1
stats.iteration_total += iteration_count
stats.iteration_max = max(stats.iteration_max, iteration_count)
if converged:
stats.iteration_converged += 1
else:
stats.iteration_nonconverged += 1
def _record_cache(
self,
key: str,
*,
operation: str,
layer: str,
medium: str,
hits: int,
misses: int,
) -> None:
stats = self._property_stats(
key,
operation=operation,
layer=layer,
medium=medium,
)
hit_delta = max(0, hits)
miss_delta = max(0, misses)
stats.cache_hits += hit_delta
stats.cache_misses += miss_delta
stats.cache_lookups += hit_delta + miss_delta
def snapshot(self) -> dict[str, object]:
"""Return a detached, JSON-serializable copy of all counters."""
audit = self.mode == "audit"
return {
"mode": self.mode,
"phases": {
name: self._phases[name].snapshot()
for name in sorted(self._phases)
},
"properties": {
_public_property_key(key): self._properties[key].snapshot(audit=audit)
for key in sorted(self._properties)
},
"propertyOutermostNs": self._property_outermost_ns,
}
_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar(
"simulation_performance_trace",
default=None,
)
_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar(
"simulation_performance_spans",
default=(),
)
_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar(
"simulation_property_shadow",
default=None,
)
def _public_property_key(key: str) -> str:
layer, medium, operation = key.split("|", 2)
return f"{layer}.{medium}.{operation}"
@contextmanager
def _tracked_span(
trace: PerformanceTrace,
name: str,
*,
property_key: str | None = None,
property_operation: str | None = None,
reset_property_shadow: bool = False,
) -> Generator[None, None, None]:
stack = _ACTIVE_SPANS.get()
property_outermost = property_key is not None and not any(
item.property_key is not None for item in stack
)
frame = _ActiveSpan(
trace=trace,
name=name,
started_ns=perf_counter_ns(),
property_key=property_key,
property_operation=property_operation,
property_outermost=property_outermost,
)
stack_token = _ACTIVE_SPANS.set((*stack, frame))
shadow_token: Token[set[object] | None] | None = None
if reset_property_shadow and trace.mode == "audit":
shadow_token = _PROPERTY_SHADOW.set(set())
error = False
try:
yield
except BaseException:
error = True
raise
finally:
elapsed_ns = max(0, perf_counter_ns() - frame.started_ns)
_ACTIVE_SPANS.reset(stack_token)
if shadow_token is not None:
_PROPERTY_SHADOW.reset(shadow_token)
if stack:
stack[-1].child_ns += elapsed_ns
trace._record_span(frame, elapsed_ns, error)
@contextmanager
def profile_run() -> Generator[PerformanceTrace, None, None]:
"""Create and bind an isolated trace for one simulation run.
The yielded trace remains usable after the context exits, which lets the
caller attach ``trace.snapshot()`` to a result without exposing live state.
"""
trace = PerformanceTrace(mode=PROFILE_MODE)
trace_token = _CURRENT_TRACE.set(trace)
spans_token = _ACTIVE_SPANS.set(())
shadow_token = _PROPERTY_SHADOW.set(set())
try:
if trace.enabled:
with _tracked_span(trace, "simulation.total"):
yield trace
else:
yield trace
finally:
_PROPERTY_SHADOW.reset(shadow_token)
_ACTIVE_SPANS.reset(spans_token)
_CURRENT_TRACE.reset(trace_token)
@contextmanager
def performance_span(
name: str,
minimum_mode: str = "standard",
reset_property_shadow: bool = False,
) -> Generator[None, None, None]:
"""Time a block in the current run, or act as a no-op outside one."""
minimum = _minimum_mode(minimum_mode)
trace = _CURRENT_TRACE.get()
if trace is None or not _mode_enabled(minimum):
yield
return
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
yield
def profile_phase(
name: str,
minimum_mode: str = "standard",
reset_property_shadow: bool = False,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorate a simulation phase while preserving the off-mode callable."""
minimum = _minimum_mode(minimum_mode)
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not _mode_enabled(minimum):
return function
if inspect.iscoroutinefunction(function):
@wraps(function)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
trace = _CURRENT_TRACE.get()
if trace is None:
return await function(*args, **kwargs)
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
return await function(*args, **kwargs)
return cast(Callable[_P, _R], async_wrapper)
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
trace = _CURRENT_TRACE.get()
if trace is None:
return function(*args, **kwargs)
with _tracked_span(
trace,
name,
reset_property_shadow=reset_property_shadow,
):
return function(*args, **kwargs)
return wrapper
return decorate
def _medium_name(args: tuple[object, ...]) -> str:
if not args:
return "unknown"
owner = args[0]
configured_name = getattr(owner, "name", None)
if isinstance(configured_name, str) and configured_name:
return configured_name
return type(owner).__name__
def _fingerprint(value: object) -> object:
"""Build a hashable, bit-exact token without retaining arbitrary objects."""
if value is None or isinstance(value, (bool, int, str, bytes)):
return (type(value).__name__, value)
if isinstance(value, float):
return ("float64", struct.pack("!d", value))
if isinstance(value, tuple):
return ("tuple", tuple(_fingerprint(item) for item in value))
if isinstance(value, list):
return ("list", tuple(_fingerprint(item) for item in value))
if isinstance(value, Mapping):
items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()]
items.sort(key=repr)
return ("mapping", tuple(items))
return (
"object",
type(value).__module__,
type(value).__qualname__,
id(value),
)
def _input_fingerprint(
signature: inspect.Signature | None,
args: tuple[object, ...],
kwargs: dict[str, object],
) -> object:
if signature is not None:
try:
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
return tuple(
(name, _fingerprint(value))
for name, value in bound.arguments.items()
)
except TypeError:
pass
return (
_fingerprint(args),
tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())),
)
def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None:
cache_info = getattr(function, "cache_info", None)
if not callable(cache_info):
return None
try:
info = cache_info()
return int(info.hits), int(info.misses)
except (AttributeError, TypeError, ValueError):
return None
def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None:
for attribute in ("cache_clear", "cache_info", "cache_parameters"):
value = getattr(source, attribute, None)
if value is not None:
setattr(target, attribute, value)
def profile_property(
operation: str,
layer: str = "semantic",
minimum_mode: str = "audit",
capture_inputs: bool = True,
track_cache: bool = False,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorate one thermodynamic property operation."""
minimum = _minimum_mode(minimum_mode)
def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]:
if not _mode_enabled(minimum):
return function
try:
signature: inspect.Signature | None = inspect.signature(function)
except (TypeError, ValueError):
signature = None
def prepare(
args: tuple[object, ...],
kwargs: dict[str, object],
) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]:
trace = _CURRENT_TRACE.get()
medium = _medium_name(args)
key = f"{layer}|{medium}|{operation}"
if trace is not None and trace.mode == "audit" and capture_inputs:
trace._record_exact_input(
key,
operation=operation,
layer=layer,
medium=medium,
fingerprint=_input_fingerprint(signature, args, kwargs),
)
before = _cache_counts(function) if trace is not None and track_cache else None
return trace, medium, key, before
def finish_cache(
trace: PerformanceTrace | None,
medium: str,
key: str,
before: tuple[int, int] | None,
) -> None:
if trace is None or before is None:
return
after = _cache_counts(function)
if after is None:
return
trace._record_cache(
key,
operation=operation,
layer=layer,
medium=medium,
hits=after[0] - before[0],
misses=after[1] - before[1],
)
if inspect.iscoroutinefunction(function):
@wraps(function)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any:
object_args = cast(tuple[object, ...], args)
object_kwargs = cast(dict[str, object], kwargs)
trace, medium, key, before = prepare(object_args, object_kwargs)
try:
if trace is None:
return await function(*args, **kwargs)
with _tracked_span(
trace,
f"property.{_public_property_key(key)}",
property_key=key,
property_operation=operation,
):
return await function(*args, **kwargs)
finally:
finish_cache(trace, medium, key, before)
_copy_cache_api(function, async_wrapper)
return cast(Callable[_P, _R], async_wrapper)
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
object_args = cast(tuple[object, ...], args)
object_kwargs = cast(dict[str, object], kwargs)
trace, medium, key, before = prepare(object_args, object_kwargs)
try:
if trace is None:
return function(*args, **kwargs)
with _tracked_span(
trace,
f"property.{_public_property_key(key)}",
property_key=key,
property_operation=operation,
):
return function(*args, **kwargs)
finally:
finish_cache(trace, medium, key, before)
_copy_cache_api(function, wrapper)
return wrapper
return decorate
def record_property_iterations(
operation: str,
iterations: int,
converged: bool,
) -> None:
"""Record inverse-property solver iterations in audit mode."""
trace = _CURRENT_TRACE.get()
if trace is None or trace.mode != "audit":
return
for frame in reversed(_ACTIVE_SPANS.get()):
if (
frame.property_key is not None
and frame.property_operation == operation
):
layer, medium, _unused_operation = frame.property_key.split("|", 2)
trace._record_iterations(
frame.property_key,
operation=operation,
layer=layer,
medium=medium,
iterations=iterations,
converged=converged,
)
return
key = f"semantic|unknown|{operation}"
trace._record_iterations(
key,
operation=operation,
layer="semantic",
medium="unknown",
iterations=iterations,
converged=converged,
)
__all__ = [
"PROFILE_MODE",
"PerformanceTrace",
"performance_span",
"profile_phase",
"profile_property",
"profile_run",
"record_property_iterations",
]
+2
View File
@@ -14,6 +14,7 @@ from app.simulation.components.amesim.flow.pipes import (
) )
from app.simulation.core.equations import EquationResidual from app.simulation.core.equations import EquationResidual
from app.simulation.core.ports import PortState, VariableRole from app.simulation.core.ports import PortState, VariableRole
from app.simulation.performance import profile_phase
from app.simulation.systems.network import SimulationNetwork from app.simulation.systems.network import SimulationNetwork
@@ -1286,6 +1287,7 @@ class PressureFlowSolver:
), ),
} }
@profile_phase("simulation.pressure_flow", minimum_mode="audit")
def solve( def solve(
self, self,
*, *,
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from math import isfinite from math import isfinite
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -36,6 +37,7 @@ class PneumaticVolumeResolver:
result[second] = first result[second] = first
return result return result
@profile_phase("simulation.pneumatic_volume", minimum_mode="audit")
def solve(self) -> PneumaticVolumeDiagnostics: def solve(self) -> PneumaticVolumeDiagnostics:
for component in self.network.components.values(): for component in self.network.components.values():
for definition in component.active_port_definitions: for definition in component.active_port_definitions:
+2
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from math import isfinite from math import isfinite
from typing import Protocol from typing import Protocol
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -47,6 +48,7 @@ class SignalResolver:
] ]
self.last_diagnostics: SignalSolveDiagnostics | None = None self.last_diagnostics: SignalSolveDiagnostics | None = None
@profile_phase("simulation.signal", minimum_mode="audit")
def solve(self, time: float) -> SignalSolveDiagnostics: def solve(self, time: float) -> SignalSolveDiagnostics:
for component in self.network.components.values(): for component in self.network.components.values():
signal_output_values = getattr(component, "signal_output_values", None) signal_output_values = getattr(component, "signal_output_values", None)
+2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.performance import profile_phase
import math import math
from dataclasses import dataclass from dataclasses import dataclass
@@ -1018,6 +1019,7 @@ def _integrate_scipy_stepwise(
) )
@profile_phase("simulation.integration")
def integrate_ode( def integrate_ode(
rhs: Callable[[float, list[float]], list[float]], rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float], initial_state: list[float],
+23 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from app.simulation.core.base import DynamicComponent from app.simulation.core.base import DynamicComponent
from app.simulation.performance import profile_phase
from app.simulation.systems.network import Endpoint, SimulationNetwork from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -83,14 +84,33 @@ class StreamResolver:
) )
return values return values
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(
self,
components: list[DynamicComponent],
) -> None:
for component in components:
component.refresh_thermodynamic_ports()
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_stream_components(
self,
connected: dict[str, dict[str, float]],
) -> None:
for component in self.network.components.values():
if isinstance(component, DynamicComponent):
component.refresh_thermodynamic_ports()
else:
component.update_stream_outflows(connected[component.name])
@profile_phase("simulation.stream", minimum_mode="audit")
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]: def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
dynamic_components = [ dynamic_components = [
component component
for component in self.network.components.values() for component in self.network.components.values()
if isinstance(component, DynamicComponent) if isinstance(component, DynamicComponent)
] ]
for component in dynamic_components: self._refresh_dynamic_components(dynamic_components)
component.refresh_thermodynamic_ports()
max_delta = 0.0 max_delta = 0.0
for iteration in range(1, self.max_iterations + 1): for iteration in range(1, self.max_iterations + 1):
@@ -100,11 +120,7 @@ class StreamResolver:
for port_name, port in component.ports.items() for port_name, port in component.ports.items()
} }
connected = self.connected_enthalpies() connected = self.connected_enthalpies()
for component in self.network.components.values(): self._refresh_stream_components(connected)
if isinstance(component, DynamicComponent):
component.refresh_thermodynamic_ports()
else:
component.update_stream_outflows(connected[component.name])
deltas = [ deltas = [
abs(port.h_outflow - previous[(component.name, port_name)]) abs(port.h_outflow - previous[(component.name, port_name)])
+155 -130
View File
@@ -7,6 +7,7 @@ from typing import Literal
from app.simulation.core.base import DynamicComponent from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.performance import performance_span, profile_phase
from app.simulation.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.mechanical import ( from app.simulation.solvers.mechanical import (
MechanicalConstraintGroup, MechanicalConstraintGroup,
@@ -337,6 +338,7 @@ def simulation_sample_times(
class GenericFluidSystem: class GenericFluidSystem:
"""Topology-driven, semi-explicit fluid simulation for registered components.""" """Topology-driven, semi-explicit fluid simulation for registered components."""
@profile_phase("simulation.system_construction")
def __init__(self, network: SimulationNetwork) -> None: def __init__(self, network: SimulationNetwork) -> None:
issues = simulation_preparation_issues(network) issues = simulation_preparation_issues(network)
if issues: if issues:
@@ -465,14 +467,18 @@ class GenericFluidSystem:
"colorGroupCount": group_count, "colorGroupCount": group_count,
} }
@profile_phase(
"simulation.closure",
minimum_mode="audit",
reset_property_shadow=True,
)
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]: def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
signal = self.signal_resolver.solve(time) signal = self.signal_resolver.solve(time)
self.signal_propagation_count += signal.propagated self.signal_propagation_count += signal.propagated
self.pressure_flow_solver.propagate_equal_efforts(("x", "v")) self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
pneumatic_volume = self.pneumatic_volume_resolver.solve() pneumatic_volume = self.pneumatic_volume_resolver.solve()
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
for component in self.dynamic_components: self._refresh_dynamic_components()
component.refresh_thermodynamic_ports()
algebraic = self.pressure_flow_solver.solve( algebraic = self.pressure_flow_solver.solve(
effort_variables=("p",), effort_variables=("p",),
) )
@@ -549,18 +555,31 @@ class GenericFluidSystem:
) )
return connected_h return connected_h
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
for component in self.dynamic_components:
component.refresh_thermodynamic_ports()
@profile_phase("simulation.derivatives", minimum_mode="audit")
def _state_derivatives(
self,
connected_h: dict[str, dict[str, float]],
) -> list[float]:
return self.pneumatic_storage_reducer.coupled_derivatives(
self.mechanical_state_reducer.state_derivatives(connected_h)
)
def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]: def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]:
state = self.initial_state_vector() state = self.initial_state_vector()
self.apply_state_vector(state) self.apply_state_vector(state)
self._close_current_state(time) self._close_current_state(time)
return state return state
@profile_phase("simulation.rhs", minimum_mode="audit")
def rhs(self, _time: float, state_vector: list[float]) -> list[float]: def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector) self.apply_state_vector(state_vector)
connected_h = self._close_current_state(_time) connected_h = self._close_current_state(_time)
return self.pneumatic_storage_reducer.coupled_derivatives( return self._state_derivatives(connected_h)
self.mechanical_state_reducer.state_derivatives(connected_h)
)
def _append_current_state(self, series: dict[str, list[float]]) -> None: def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.network.components.values(): for component in self.network.components.values():
@@ -603,20 +622,26 @@ class GenericFluidSystem:
progress_callback(last_reported_progress, phase) progress_callback(last_reported_progress, phase)
report_progress(0.0, "initializing", force=True) report_progress(0.0, "initializing", force=True)
integration_config = config with performance_span("simulation.sample_initialization"):
if isinstance(config.atol, (int, float)): integration_config = config
integration_config = replace( if isinstance(config.atol, (int, float)):
config, integration_config = replace(
atol=self.mechanical_state_reducer.absolute_tolerances( config,
float(config.atol) atol=self.mechanical_state_reducer.absolute_tolerances(
), float(config.atol)
),
)
t_eval = simulation_sample_times(config, sample_step)
signal_event_times = self.signal_resolver.event_times(
config.t_start,
config.t_stop,
)
initial_state = self.consistent_initial_state_vector(config.t_start)
jac_sparsity = (
self.jacobian_sparsity()
if integration_config.method in {"BDF", "Radau"}
else None
) )
t_eval = simulation_sample_times(config, sample_step)
signal_event_times = self.signal_resolver.event_times(
config.t_start,
config.t_stop,
)
initial_state = self.consistent_initial_state_vector(config.t_start)
report_progress(0.0, "integrating", force=True) report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start furthest_solver_time = config.t_start
@@ -651,11 +676,7 @@ class GenericFluidSystem:
if self.mechanical_state_reducer.has_state_events if self.mechanical_state_reducer.has_state_events
else None else None
), ),
jac_sparsity=( jac_sparsity=jac_sparsity,
self.jacobian_sparsity()
if integration_config.method in {"BDF", "Radau"}
else None
),
) )
if isinstance(solution, ODESolution): if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status run_status: SimulationRunStatus = solution.status
@@ -719,112 +740,116 @@ class GenericFluidSystem:
for segment in solver_segment_diagnostics for segment in solver_segment_diagnostics
) )
series: dict[str, list[float]] = {"time": []} with performance_span("simulation.postprocessing"):
postprocessing_error: Exception | None = None series: dict[str, list[float]] = {"time": []}
self.mechanical_state_reducer.reset_constraint_modes() postprocessing_error: Exception | None = None
for time_index in range(len(times)): self.mechanical_state_reducer.reset_constraint_modes()
if ( for time_index in range(len(times)):
run_status == "completed" if (
and cancel_check is not None run_status == "completed"
and cancel_check() and cancel_check is not None
): and cancel_check()
run_status = "cancelled" ):
result_message = "Simulation was stopped while preparing partial results." run_status = "cancelled"
break result_message = (
state = [ "Simulation was stopped while preparing partial results."
float(solution.y[state_index][time_index]) )
for state_index in range(len(solution.y)) break
] state = [
try: float(solution.y[state_index][time_index])
self.apply_state_vector(state) for state_index in range(len(solution.y))
self._close_current_state(times[time_index]) ]
self._append_current_state(series) try:
series["time"].append(times[time_index]) self.apply_state_vector(state)
except Exception as exc: self._close_current_state(times[time_index])
run_status = "failed" self._append_current_state(series)
result_message = str(exc) series["time"].append(times[time_index])
postprocessing_error = exc except Exception as exc:
break run_status = "failed"
if len(series["time"]) < 2: result_message = str(exc)
if postprocessing_error is not None: postprocessing_error = exc
raise postprocessing_error break
if integration_error is not None: if len(series["time"]) < 2:
raise integration_error if postprocessing_error is not None:
raise postprocessing_error
if integration_error is not None:
raise integration_error
final = { with performance_span("simulation.result_assembly"):
key: values[-1] final = {
for key, values in series.items() key: values[-1]
if key != "time" and values for key, values in series.items()
} if key != "time" and values
diagnostics = { }
"integration": { diagnostics = {
"method": integration_config.method, "integration": {
"jacobianSparsity": jacobian_diagnostics, "method": integration_config.method,
"segmentCount": len(solver_segment_diagnostics), "jacobianSparsity": jacobian_diagnostics,
"segments": solver_segment_diagnostics, "segmentCount": len(solver_segment_diagnostics),
"totals": solver_totals, "segments": solver_segment_diagnostics,
}, "totals": solver_totals,
"pressureFlow": { },
"solveCount": self.algebraic_solve_count, "pressureFlow": {
"maxScaledResidual": self.max_algebraic_residual, "solveCount": self.algebraic_solve_count,
"maxEvaluationsPerSolve": self.max_algebraic_evaluations, "maxScaledResidual": self.max_algebraic_residual,
"last": ( "maxEvaluationsPerSolve": self.max_algebraic_evaluations,
self.pressure_flow_solver.last_diagnostics.as_dict() "last": (
if self.pressure_flow_solver.last_diagnostics is not None self.pressure_flow_solver.last_diagnostics.as_dict()
else None if self.pressure_flow_solver.last_diagnostics is not None
else None
),
},
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,
"maxThermofluidIterations": self.max_thermofluid_iterations,
"last": (
self.stream_resolver.last_diagnostics.as_dict()
if self.stream_resolver.last_diagnostics is not None
else None
),
},
"signal": {
"propagations": self.signal_propagation_count,
"eventTimes": list(signal_event_times),
"last": (
self.signal_resolver.last_diagnostics.as_dict()
if self.signal_resolver.last_diagnostics is not None
else None
),
},
"pneumaticVolume": {
"propagations": self.pneumatic_volume_propagation_count,
"last": (
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
if self.pneumatic_volume_resolver.last_diagnostics is not None
else None
),
},
"stateCount": len(initial_state),
"sampleCount": len(series["time"]),
}
variables = tuple(
variable
for variable in self.network.result_variable_metadata()
if variable.key in series
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
force=True,
)
return GenericSimulationResult(
success=run_status == "completed" and bool(solution.success),
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
), ),
}, requested_stop_time=float(config.t_stop),
"stream": { variables=variables,
"maxIterationsPerSolve": self.max_stream_iterations, series=series,
"maxThermofluidIterations": self.max_thermofluid_iterations, final=final,
"last": ( diagnostics=diagnostics,
self.stream_resolver.last_diagnostics.as_dict() )
if self.stream_resolver.last_diagnostics is not None
else None
),
},
"signal": {
"propagations": self.signal_propagation_count,
"eventTimes": list(signal_event_times),
"last": (
self.signal_resolver.last_diagnostics.as_dict()
if self.signal_resolver.last_diagnostics is not None
else None
),
},
"pneumaticVolume": {
"propagations": self.pneumatic_volume_propagation_count,
"last": (
self.pneumatic_volume_resolver.last_diagnostics.as_dict()
if self.pneumatic_volume_resolver.last_diagnostics is not None
else None
),
},
"stateCount": len(initial_state),
"sampleCount": len(series["time"]),
}
variables = tuple(
variable
for variable in self.network.result_variable_metadata()
if variable.key in series
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
force=True,
)
return GenericSimulationResult(
success=run_status == "completed" and bool(solution.success),
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
),
requested_stop_time=float(config.t_stop),
variables=variables,
series=series,
final=final,
diagnostics=diagnostics,
)
+2
View File
@@ -10,6 +10,7 @@ from typing import Literal
from lxml import etree from lxml import etree
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.performance import profile_phase
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
from app.simulation.solvers.solver import SolveIVPConfig from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import ( from app.simulation.systems.generic import (
@@ -191,6 +192,7 @@ class SystemXmlValidationReport:
return result return result
@profile_phase("simulation.xml_validation")
def validate_system_xml_document(source: bytes | str) -> SystemXmlValidationReport: def validate_system_xml_document(source: bytes | str) -> SystemXmlValidationReport:
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
if not xml_bytes.strip(): if not xml_bytes.strip():
+5
View File
@@ -9,6 +9,11 @@
- [更新日志 2026-08-15](更新日志-2026-08-15.md) - [更新日志 2026-08-15](更新日志-2026-08-15.md)
## 求解与性能
- [后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
- [仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)
## 模型开发 ## 模型开发
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md) 1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
+119
View File
@@ -0,0 +1,119 @@
# SystemSimulationApp 仿真性能评估(2026-08-15)
> 代码基线:`model-development@6a06489`,随后只加入本报告所述的可选埋点和基准工具。
> 本次评估的是前端流式接口实际使用的 System XML 求解路径;所有时间均为本机实测,不代表其他机器的绝对性能。
## 1. 结论
1. **压力—流量闭合是当前首要热点。** 深度审计中,三个气动短算例有 71%~85% 的计时落在 `PressureFlowSolver.solve()` 的包含时间内。它同时包含残差组装及其触发的物性调用,不能与物性时间相加。
2. **物性调用存在很高的完全相同输入重复率。** 按每次代数闭合重置精确输入影子集合后,空气链路、空气分支和氦气阶跃的重复率分别为 91.2%、96.5% 和 82.3%。空气公式很便宜,不能只凭重复率加缓存;Peng–Robinson 氦气更值得优化。
3. **现有两项氦气 LRU 精确缓存有效。** 冷缓存审计中,`properties_from_mU` 命中率 95.5%,`temperature_from_pressure_enthalpy` 命中率 78.6%;21 次配对端到端测试中,暖缓存比每次清空缓存快约 7.9%。这些缓存已经存在于评估基线,本次没有新增或改变缓存算法。
4. **长仿真的时间主要花在积分阶段。** 10 s 氦气均压算例耗时约 10.6~11.5 s,其中标准埋点测得积分占 90.5%,初始化约 4.2%,逐采样点后处理约 5.0%。
5. **结果 JSON 暂不是这些算例的首要矛盾。** 四个短算例的最终 NDJSON 结果约 29~59 KiB,编码中位数约 0.4~1.2 ms;501 个采样点的长算例约 507 KiB,编码约 18.5 ms。
6. **首次仿真有明显冷启动。** 新 Python 进程第一次短算例约 0.71 s,预热后同类算例约 0.06~0.13 s。剖析表明首次进入 SciPy 求解路径的惰性导入占了主要差额;这是服务首请求延迟,不是稳态吞吐。
7. **用户提供的 demo XML 尚不能形成完整性能样本。** 它仍在 `0.000175 s` 左右因 `Initial guess is outside of provided bounds` 失败;深度埋点确认错误发生在压力—流量闭合。本批只记录失败路径,没有顺带改变求解器容错行为。
## 2. 埋点实现与污染控制
性能开关由进程启动环境变量 `SIMULATIONAPP_PROFILE` 决定:
| 模式 | 用途 | 记录内容 | 适合场景 |
| --- | --- | --- | --- |
| `off` | 正常运行,默认值 | 不在响应中加入性能数据;装饰器在模块加载时直接返回原函数 | 正式仿真和最终性能对比 |
| `standard` | 低开销阶段统计 | XML 校验、网络编译、系统构造、初始化、积分、后处理、结果组装 | 日常定位“大阶段” |
| `audit` | 深度审计 | 再展开 RHS、代数闭合、压力流量、stream、刷新、导数和物性内核 | 短算例诊断、调用频率与缓存评估 |
一次运行使用一个 `ContextVar` 隔离的 `PerformanceTrace`,不会把不同仿真任务的阶段计数混在一起。成功或失败的求解结果在 profiling 模式下都会把快照放入 `diagnostics.performance`。主要字段为:
- 阶段:`calls`、`inclusiveNs`、`selfNs`、`maxNs`、`errors`;
- 物性:上述时间字段,以及介质、操作、缓存查询/命中/未命中;
- audit 专有:闭合内精确输入唯一数/重复数、逆解迭代总数/最大值/收敛与未收敛次数;
- `propertyOutermostNs`:只累计最外层物性调用,避免把嵌套 PR 内核时间重复相加。
标准模式只保留低频的大阶段计时。21 次氦气阶跃配对运行中,标准模式相对关闭模式的中位开销为 1.9%;四个短算例分开校准为 0.5%~2.9%。audit 会逐次生成精确指纹并计时,短算例可慢到约 2.5~5 倍,因此 audit 数据用于定位和计数,最终优化收益必须回到 `off` 模式复测。
将当前代码的 `off` 模式与备份提交 `6a06489` 同时运行 21 次氦气阶跃,墙钟中位数差为约 0.3%,处于本机噪声范围。也就是说,默认关闭时没有观察到稳定的热路径退化。
## 3. 测试方法
环境:Windows 11、Python 3.12.3、SciPy 1.18.0、64 位 Intel 处理器。仓库没有 PyInstaller/Nuitka 等可执行文件构建链,本次直接使用项目实际启动后端的 `.venv-win` 解释器。把同一 Python 代码再包成单文件只会混入解包和启动成本,不会使这里的求解内核更接近生产路径。
基准工具入口:
```powershell
.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance `
--mode audit --warmups 1 --runs 3 `
--factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" `
--output app/data/performance-evaluations/helium-step.json
```
工具默认传入取消检查回调,从而走与前端流式仿真相同的低层逐步积分路径。它记录墙钟、进程 CPU、最终 NDJSON 编码、输入 SHA-256 和完整性能快照。原始 JSON 写入被 Git 忽略的 `app/data/performance-evaluations/`,避免把机器相关的大量样本提交到仓库。
本次代表算例:
| 算例 | 内容 | 暖机后 `off` 墙钟中位数 | 重复次数 |
| --- | --- | ---: | ---: |
| `air_chain` | 空气气缸—节流孔—管路—储罐 | 62.4 ms | 9 |
| `air_branched` | 空气分支网络 | 130.3 ms | 9 |
| `helium_step` | 高压 PR 氦气、信号阶跃阀 | 65.2 ms | 9 |
| `mechanical_contact` | MECMAS21/LSTP00A 弹性接触 | 33.0 ms | 9 |
| `helium_long` | 10 s PR 氦气均压、501 个输出点 | 10.63 s | 1 |
短算例先暖机 2 次再测 9 次;缓存 A/B 使用两个同时启动的独立进程各暖机 5 次、测量 21 次,以尽量抵消瞬时系统负载。长算例只测 1 次,因此它只用于判断数量级与阶段占比。
## 4. 深度阶段结果
下表时间是 audit 中位数,会包含审计自身开销;调用数和相对热点比绝对时间更可靠。
| 算例 | RHS | 完整闭合 | 压力流量求解 | 压力流量包含时间占 audit 总时间 | 物性调用 | 闭合内精确重复率 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `air_chain` | 33 | 37 | 74 | 77.0% | 4,265 | 91.2% |
| `air_branched` | 23 | 26 | 56 | 84.6% | 7,378 | 96.5% |
| `helium_step` | 79 | 102 | 235 | 71.2% | 11,121 | 82.3% |
| `mechanical_contact` | 74 | 78 | 156 | 29.5% | 0 | 不适用 |
当前热流耦合不是旧文档所写的固定 2~3 次压力求解。每次闭合先做 1 次压力求解,然后最多执行 25 轮 `stream → pressure-flow` 固定点;也就是说理论上最多 26 次。本次四个算例的平均压力求解次数/闭合分别为 2.00、2.15、2.30 和 2.00。优化时应编译依赖/脏标记并减少不必要的全网 pass,但不能直接删除第二轮,否则会重新引入求值历史依赖并破坏有限差分 Jacobian。
## 5. 物性调用与缓存结果
氦气阶跃的冷缓存 audit 代表运行:
| 操作 | 调用 | 命中/未命中 | 命中率 | 真实逆解次数 | 平均迭代 | 最大迭代 | 未收敛 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `properties_from_mU` | 1,930 | 1,843 / 87 | 95.5% | 87 | 3.99 | 5 | 0 |
| `temperature_from_pressure_enthalpy` | 398 | 313 / 85 | 78.6% | 85 | 5.00 | 5 | 0 |
暖机后以相同配置重复运行,这两项在代表快照中均为 100% 命中,说明当前精确 LRU 能跨同配置运行复用确定性轨迹。关闭埋点的端到端配对结果为:暖缓存中位数 77.05 ms,每次清空缓存为 83.69 ms;换算为暖缓存约快 7.9%。
audit 的自身时间排序还显示:`isentropic_density_pressure_factor` 调用 398 次,`density` 业务入口及 PR 密度内核各调用 1,198 次,PR `compressibility_roots` 调用 1,200 次。同一 `(p,T)` 周围存在“等熵因子内部求密度,随后流量公式再次求密度”的重复机会。这里应优先复用同一闭合内的精确结果或合并 API;不要用四舍五入/容差键缓存,否则会在残差函数中制造平台并影响 ODE/least-squares 的有限差分。
空气算例虽然精确重复率更高,但理想气体公式本身只有少量算术。对这些廉价函数增加字典查询可能比重算更慢,应先做专门 A/B,不应套用氦气结论。
## 6. 输出与失败样本
| 算例 | 最终结果大小 | NDJSON 编码中位数 |
| --- | ---: | ---: |
| `air_chain` | 29.2 KiB | 0.39 ms |
| `air_branched` | 59.1 KiB | 1.21 ms |
| `helium_step` | 55.5 KiB | 1.10 ms |
| `mechanical_contact` | 51.4 KiB | 0.62 ms |
| `helium_long` | 507.4 KiB | 18.48 ms |
用户 demo 的输入 SHA-256 为 `27048a99da0a21922d75785b760c3b5d04be3349b8aef6fbfedfd811d87ef1d5`。audit 失败运行记录到 80 次 RHS、83 次闭合、165 次压力流量求解,最后一项各有 1 次错误;这与此前定位的低压试探态越过 `least_squares` 初值边界一致。由于没有到达 10 s 终点,不能把其 1.08 s 失败耗时当作完整模型性能。
## 7. 后续优化顺序
1. **先优化压力流量执行计划。** 继续预编译组件/连接残差归属、显式赋值顺序和热流耦合脏标记;增加快路径命中率、非线性 `nfev` 累计耗时,区分“全网扫描慢”与“非线性迭代慢”。
2. **再减少 PR 物性重复。** 复用组件当前 `(m,U,V)` 的状态恢复结果,合并等熵因子与密度读取;沿用精确键、有界容量和按仿真隔离原则。现有 LRU 已带来约 8% 的短算例收益,不应回退。
3. **处理冷启动。** 若首请求延迟重要,可在 worker 启动时显式导入 SciPy 求解模块或运行一个极小、无业务副作用的预热模型;不要把约 0.65 s 冷启动归因到每次仿真。
4. **长算例再看后处理复用。** 当前代表长算例的积分占 90.5%,所以积分/闭合仍优先;当采样更密或变量更多时,再评估复用已接受状态闭合、按需变量和降采样。
5. **把 demo 数值容错作为独立修复。** 统一压力可行域与初值投影、将试探态错误转为可恢复拒步,并在闭合前刷新外部容积缓存;该改动需要单独回归,不能混入性能优化提交。
## 8. 本次评估边界
- 没有固定 CPU 亲和性或关闭后台程序,短算例绝对时间存在数毫秒波动,因此以中位数和配对实验为主。
- audit 会显著改变廉价函数的单次耗时;不能把 audit 的物性毫秒数直接当成关闭埋点后的真实占比。
- 当前 LRU 命中/未命中来自调用前后的全局 `cache_info()` 差值;本报告均为单任务运行。多个 audit 仿真线程同时调用同一缓存时,阶段/物性调用仍按 trace 隔离,但缓存命中差值可能交错,不能用来做并发结论。
- 直接抛出 `HTTPException` 的校验/执行异常会结束 trace,但当前不会把快照附到错误响应;demo 属于返回 `failed` 部分结果的路径,所以本报告能够取得其失败快照。
- 本批没有测峰值 RSS、1/2/4 并发吞吐、浏览器解析/绘图或 8/32 单元拓扑扩展曲线。
- 没有为评估引入新的近似缓存、容差调整或求解器算法变更;所有性能结论都与数值优化改动解耦。
+36 -36
View File
@@ -1,7 +1,7 @@
# SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版) # SystemSimulationApp 后端求解逻辑与效率优化调研(通俗版)
> 调研基线:2026-08-12(System XML v3 迁移后),依据当前仓库代码、配置、说明文档与测试。 > 调研基线:2026-08-15(System XML v3 迁移后),依据当前仓库代码、配置、说明文档、测试与阶段埋点。
> 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。文中没有把静态代码分析冒充 CPU、内存实测。 > 本文所称“主求解路径”是当前前端实际调用的 System XML 流式接口;固定 TestModel 和 Test MQL 接口另行说明。机器相关的实测结果单独见[仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)。
## 0. 三分钟读懂 ## 0. 三分钟读懂
@@ -74,7 +74,7 @@
1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。 1. **[已实现] 当前主内核是“半显式 ODE + RHS 内代数闭合”。** 动态组件只把储能状态交给 ODE 积分器;每次计算导数前,系统先传播信号、刷新热力状态、求压力/流量代数网络、传播变容边界、迭代 stream 焓并更新机械加速度。它不是通用 DAE 求解器,也不等价于完整 Modelica `inStream/actualStream` 语义(`README.md:18-20`、`app/simulation/README.md:174-195`)。
2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。 2. **[已实现] 当前前端主链路是 System XML 流式仿真。** 浏览器生成 XML,经 `POST /api/system-xml/simulate-stream` 发送;后端以 NDJSON 返回心跳与进度,最后在一个 JSON 行中返回完整结果。不是 WebSocket 或标准 SSE。
3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。 3. **[已实现] XML v3 的 `sampleStep` 是输出采样间隔,不是固定积分步长。** 内部的 `max_step`(XML 为 `maxStep`)才是自适应积分步长上限;`BDF/Radau/LSODA/RK45/RK23/DOP853` 均受支持。流式运行因总是提供取消检查,会使用 SciPy 低层求解器逐个已接受步推进。
4. **[已实现] 每次 RHS 的完整闭合至少调用 2 次、存在外部容积传播时最多调用 3 次压力流量求解。** 积分结束后,每个输出采样点又执行一次完整闭合并提取全部公开结果。这是当前最明确的单任务重复工作来源。 4. **[已实现] 每次完整闭合先调用 1 次压力流量求解,再执行最多 25 轮 `stream → pressure-flow` 固定点。** 因而每次闭合至少 2 次、理论上最多 26 次压力求解;本次代表算例平均为 2.00~2.30 次。积分结束后,每个输出采样点又执行一次完整闭合并提取结果。
5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。 5. **[已实现] 代数求解已有因果化快路径。** 压力流量求解器预编译相等组与显式流量计划,种子残差足够小时不调用非线性优化;否则对全局未知向量调用 SciPy `least_squares`,当前未提供解析 Jacobian 或 `jac_sparsity`。
6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。 6. **[已实现] 当前启动脚本是一个 Uvicorn worker。** 每个流式任务再创建一个无并发上限的 daemon 线程和无界队列;没有进程池、集中任务队列、CPU/内存配额或持久化作业系统。同步仿真端点还会在 `async def` 中直接执行 CPU 密集代码。
7. **[推断] 优化应分两条线:** 7. **[推断] 优化应分两条线:**
@@ -205,30 +205,25 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
- 气体携带的能量按实际流向交给下游; - 气体携带的能量按实际流向交给下游;
- 若还有机械活塞或控制信号,它们在同一时刻也要一致。 - 若还有机械活塞或控制信号,它们在同一时刻也要一致。
代码目前不是一次对完,而是按固定顺序做若干轮专项检查。因此一个“计算变化率”的请求会调用 **2 次压力/流量闭合**;涉及外部容积传播时会调用 **3 次**。这也是后文首要优化方向。 代码目前不是一次对完,而是先建立初始压力解,再让 stream 焓和压力—流量相互迭代到同一个固定点。这样做是为了让当前 RHS 不依赖上一次调用留下的焓/流量历史,并保持有限差分 Jacobian 可重复。
`GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py:258-290`: `GenericFluidSystem._close_current_state()` 的实际顺序见 `app/simulation/systems/generic.py`:
| 顺序 | 操作 | 目的 | | 顺序 | 操作 | 目的 |
| ---: | --- | --- | | ---: | --- | --- |
| 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input | | 1 | `SignalResolver.solve(time)` | 更新时间信号源并从 output 传播到 input |
| 2 | 刷新动态组件热力端口 | 由当前 `m/U/V` 恢复压力、温度、焓等 | | 2 | 传播机械 `x/v` 等值关系 | 把当前机械状态同步到刚性连接端口 |
| 3 | 第一次 `PressureFlowSolver.solve()` | 闭合当前压力、流量、机械端口代数关系 | | 3 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` |
| 4 | `PneumaticVolumeResolver.solve()` | 沿气动连接传播外部 `volume/volume_flow` | | 4 | 刷新动态组件热力端口 | 由当前 `m/U/V` 和最新体积恢复压力、温度、焓 |
| 5 | 若发生容积传播,再刷新热力状态并第二次求压力/流量 | 让变容边界进入气室状态关系 | | 5 | 第一次 `PressureFlowSolver.solve()` | 建立本轮热流固定点的初始压力和流量 |
| 6 | `StreamResolver.solve()` | 按实际流向迭代传播/混合 `h_outflow` | | 6 | 最多 25 轮 `StreamResolver.solve()` 后再求压力流量 | 让焓、温度引用和构成流量同时收敛;按流量变化判停 |
| 7 | 无条件再次求压力/流量 | 让依赖 stream/温度的构成关系重新闭合 | | 7 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
| 8 | 更新机械约束加速度 | 为机械状态导数准备 `a` |
随后 `rhs()` 才收集各动态组件的导数(`app/simulation/systems/generic.py:298-301`)。 随后 `rhs()` 才收集各动态组件的导数。
因此: 因此每次闭合至少有 **2 次**、最多有 **26 次**压力流量求解。外部容积传播会影响初始热力状态,但不再用“有没有容积传播”直接决定固定次数。stream 自身仍有相对容差 `1e-9` 和最多 100 次内部迭代;外层热流固定点最多 25 轮,两层上限不能混为一个数。
- 无外部容积传播时,每次闭合固定有 **2 次**压力流量求解; **[发现]** 这套顺序仍没有按模型实际能力完全裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系、脏标记和回归测试决定,不能只凭某个算例结果不变。
- 有外部容积传播时固定有 **3 次**;
- stream 默认相对容差 `1e-9`、最多 100 次迭代,每轮复制端口焓并扫描组件/端口(`app/simulation/solvers/stream.py:29-119`)。
**[发现]** 这套固定顺序没有按模型实际能力裁剪。例如没有信号、没有外部容积源或 stream 不反向影响构成关系的网络,仍经过对应全网 pass。是否能安全删去某一 pass 必须由依赖关系和回归测试决定,不能只凭某个算例结果不变。
## 6. 初始化、积分参数与推进方式 ## 6. 初始化、积分参数与推进方式
@@ -256,7 +251,7 @@ stream 焓 `h_outflow` 不直接强制相等;标量信号和气动外部容积
| `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` | | `sampleStep`(内部 `sample_step`) | 默认 `0.1 s` | 仅生成输出 `t_eval` 采样网格;工程 JSON 仍暂名 `simulation.step` |
| `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 | | `maxStep`(内部 `max_step`) | 默认 `0.005 s` | 自适应求解器内部已接受步的上限 |
| `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 | | `method` | 默认 `BDF` | BDF、Radau、LSODA、RK45、RK23、DOP853 |
| `rtol` | 通用 XML 路径硬编码 `1e-5` | 外层 ODE 相对误差;用户不可配置 | | `rtol` | 通用 XML 路径硬编码 `1e-6` | 外层 ODE 相对误差;用户不可配置 |
| `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 | | `atol` | `SolveIVPConfig` 标量默认 `1e-8` | 同时用于不同量纲的全部状态;用户不可配置 |
| `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 | | `first_step` | 默认 `None` | 交给 SciPy;用户不可配置 |
| 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 | | 代数残差容差 | `1e-7` | 压力流量快速路径/接受标准 |
@@ -331,9 +326,9 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
- stream 最大迭代数; - stream 最大迭代数;
- 停止状态及部分错误上下文。 - 停止状态及部分错误上下文。
**[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。现有响应还没有外层积分 `nfev/njev/nlu`、接受/拒绝步、重启次数、分阶段墙钟时间、热物性调用数、峰值 RSS、队列深度和结果字节数。 **[已实现]** 积分诊断已经包含分段及汇总的 `nfev/njev/nlu`、已接受步、求解器启动、状态迁移和可恢复重试数。设置 `SIMULATIONAPP_PROFILE=standard|audit` 后,响应还会加入分阶段墙钟时间;audit 进一步记录物性调用、精确重复、缓存命中和逆解迭代。
因此本文可静态定位重复工作,但不能用现有诊断精确量化每个热点的时间占比。 **[发现]** `_close_current_state()` 中局部变量 `algebraic` 会被后续 pass 覆盖;最大残差/评估统计只采集每次闭合最后一次压力求解,而 `solveCount` 才累计了全部调用。仍缺少压力快路径命中率/累计 `nfev`、峰值 RSS、任务队列深度等服务级指标。结果字节和编码时间目前由离线基准工具测量,不进入常规 API 响应。
## 9. 求解时前后端交流 ## 9. 求解时前后端交流
@@ -440,11 +435,11 @@ O(组件 + 连接 + 代数结构)
| 热点 | 代码证据 | 影响范围 | 判断 | | 热点 | 代码证据 | 影响范围 | 判断 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 每次闭合固定 2~3 次压力流量求解 | `generic.py:258-290` | 每个 RHS、初始化、每个结果采样点 | [已实现] 重复 pass;真实耗时待测 | | 每次闭合执行 2~26 次压力流量求解 | `generic.py` 的热流固定点 | 每个 RHS、初始化、每个结果采样点 | [实测] 代表气动算例平均 2.00~2.30 次;audit 包含时间占 71%~85% |
| stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 | | stream 每轮复制/扫描并重复刷新 | `stream.py:29-119` | 每个闭合,最多 100 轮 | [已实现];网络越大越明显 |
| 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 | | 非线性回退用全局有限差分 least-squares | `algebraic.py:927-947` | 快速路径失效时 | [已实现];大非线性网络潜在陡增 |
| 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] | | 外层刚性积分器看不到显式稀疏 Jacobian | `solver.py:528-1009` | BDF/Radau 的每步/Newton | [已实现] |
| 热物性重复反算 | `mediums.py:199-266` 及各动态组件 refresh | 每个 RHS/闭合 pass | [推断] 需调用计数确认 | | 热物性重复反算 | `mediums.py` 及各动态组件 refresh | 每个 RHS/闭合 pass | [实测] 闭合内精确重复率 82%~97%;PR 氦气缓存端到端收益约 8% |
| 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] | | 每采样点完整后处理闭合 | `generic.py:397-474` | 输出点 × 全网 | [已实现] |
| 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 | | 每个已接受步构造 dense output | `solver.py:528-914` | 流式逐步路径 | [已实现];无跨样本/事件时可能浪费 |
| 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 | | 无界求解线程与任务结果驻留 | `main.py:491-520, 773-880` | 并发任务 | [已实现] 稳定性风险,不等于单算例变慢 |
@@ -452,7 +447,7 @@ O(组件 + 连接 + 代数结构)
## 13. 优化建议排序 ## 13. 优化建议排序
以下按**预期综合收益**排序;同档位优先低风险、低难度项。收益是基于调用频率与复杂度的代码推断,不是基准测试结果。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。 以下按**预期综合收益**排序;同档位优先低风险、低难度项。排序同时参考代码结构和 2026-08-15 的阶段/物性实测,但尚未覆盖大规模拓扑与多任务吞吐。“单算例”指一个模型的墙钟时间,“吞吐”指多任务服务能力。
### 13.1 先看人话版 ### 13.1 先看人话版
@@ -460,7 +455,7 @@ O(组件 + 连接 + 代数结构)
| 顺序 | 人话方案 | 为什么可能更快 | 主要风险 | | 顺序 | 人话方案 | 为什么可能更快 | 主要风险 |
| ---: | --- | --- | --- | | ---: | --- | --- | --- |
| 1 | 少做重复“瞬时对账” | 当前每次变化率计算固定做 2~3 次压力/流量闭合,调用频率最高 | 少做一轮可能漏掉真实耦合,必须按组件依赖裁剪 | | 1 | 少做重复“瞬时对账” | 当前每次闭合做初始压力求解和热流固定点,实测压力流量层最热 | 少做一轮可能漏掉真实耦合,必须按组件依赖和脏标记裁剪 |
| 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 | | 2 | 先整理方程,再求解 | 合并重复未知量,把关联较弱的方程分组;大模型回退迭代时收益很高 | 连接、接触和跨域活塞会让分组出错 |
| 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 | | 3 | 给不同状态使用合适的“尺子” | 质量、内能、位置、速度量级差异很大;合理缩放可减少无效内部步 | 容差改变会影响精度和事件时刻 |
| 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 | | 4 | 相同输入不要重复查热物性 | 同一轮闭合中常以相同状态反算压力、温度等 | 缓存失效不严谨会产生错误结果 |
@@ -486,15 +481,13 @@ O(组件 + 连接 + 代数结构)
### 13.2 推荐落地顺序 ### 13.2 推荐落地顺序
在改算法前先增加低侵入观测,但不把“加指标”误列为直接加速: 低侵入阶段/物性观测、积分计数和代表算例首轮基准已经落地,但不把“加指标”误列为直接加速。下一步建议:
1. 记录每个闭合 pass 的调用数、墙钟时间、代数 `nfev` 与是否命中快路径; 1. 给压力流量层补快路径命中、累计非线性 `nfev` 和残差装配时间,继续拆解本次确认的首要热点;
2. 记录热物性调用数/迭代数、stream 迭代数; 2. 用同一套基准对执行计划裁剪、组件级精确物性复用做 `off` 模式 A/B;
3. 导出外层 `nfev/njev/nlu`、接受/拒绝步、事件与重启次数; 3. 补 1/8/32 单元规模曲线、1/2/4 并发吞吐和峰值 RSS;
4. 记录状态/结果数组字节数、最终 JSON 字节、任务队列深度和进程 RSS; 4. 记录状态/结果数组字节、任务队列深度;结果 JSON 字节可继续由基准工具测量;
5. 用小、中、大三类基准网络定位排名 1~5 的真实占比; 5. 再决定代数分块/Jacobian、结果按需计算和进程 worker 的实施深度。
6. 先实施第 1、4、8、9 项的可回滚改造,再决定第 2、3、5 项的深度;
7. 服务并发需求明确后并行推进第 6、7 项。
每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。 每项算法改动都应继续验证质量/能量守恒、正反流、stream 混合、机械端挡、信号断点、取消部分结果和 AMESim/TestModel 基线。相关测试证据包括 `tests/test_generic_system_xml_simulation.py:244-533`、`tests/test_core_solver.py:19-508`、`tests/test_amesim_mechanical_public_components.py`。
@@ -516,9 +509,14 @@ O(组件 + 连接 + 代数结构)
- 采样上限、超时、心跳和任务名义保留时长。 - 采样上限、超时、心跳和任务名义保留时长。
- 组件参数在单次运行中静态;时间变化通过信号源等模型表达。 - 组件参数在单次运行中静态;时间变化通过信号源等模型表达。
### 推断及必须实测 ### 已有初步实测、仍需扩大样本
- 压力流量闭合是当前代表气动短算例的首要热点;物性调用具有高精确重复率,现有 PR 缓存有可见端到端收益。
- 长氦气代表算例的积分阶段占约 90.5%,后处理约 5%。
- 上述结论仍需在更大拓扑、更多真实工程和固定硬件环境复测。
### 推断及必须继续实测
- 哪一类闭合 pass、热物性或 Jacobian 估计占主要墙钟时间。
- 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。 - 单个任务实际占用几个核心、SciPy/BLAS 原生线程数和多任务扩展曲线。
- 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。 - 典型/最大工程的峰值 RSS、结果 JSON 大小、浏览器内存副本和 sessionStorage 成功率。
- 各优化的实际收益;表中排序应在观测数据出现后更新。 - 各优化的实际收益;表中排序应在观测数据出现后更新。
@@ -537,6 +535,8 @@ O(组件 + 连接 + 代数结构)
| 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` | | 气动外部容积 | `app/simulation/solvers/pneumatic_volume.py:21-93` | `PneumaticVolumeResolver` |
| 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` | | 机械因果化与事件 | `app/simulation/solvers/mechanical.py:225-627` | `MechanicalStateReducer` |
| ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` | | ODE 推进 | `app/simulation/solvers/solver.py:37-1009` | `SolveIVPConfig`、`integrate_ode()` |
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 | | 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 | | 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 |
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 | | 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import unittest
from app.simulation.benchmark_performance import (
_duration_summary,
_load_factory_xml,
_named_value,
_serialize_result_event,
)
def sample_xml_factory() -> bytes:
return b"<System/>"
class PerformanceBenchmarkToolTests(unittest.TestCase):
def test_named_value_requires_an_explicit_name(self) -> None:
self.assertEqual(
_named_value("chain=tests.example:project", option="--factory"),
("chain", "tests.example:project"),
)
with self.assertRaisesRegex(ValueError, "NAME=VALUE"):
_named_value("tests.example:project", option="--factory")
def test_duration_summary_reports_repeatable_order_statistics(self) -> None:
summary = _duration_summary([5.0, 1.0, 3.0, 2.0, 4.0])
self.assertEqual(summary["minimumMs"], 1.0)
self.assertEqual(summary["medianMs"], 3.0)
self.assertEqual(summary["p95Ms"], 5.0)
self.assertEqual(summary["maximumMs"], 5.0)
def test_factory_loader_accepts_a_bytes_factory(self) -> None:
self.assertEqual(
_load_factory_xml(
"tests.test_performance_benchmark:sample_xml_factory"
),
b"<System/>",
)
def test_result_event_serialization_uses_ndjson_shape(self) -> None:
payload = _serialize_result_event(
{
"success": True,
"status": "completed",
"simulatedUntil": 1.0,
"requestedStopTime": 1.0,
}
)
self.assertTrue(payload.endswith(b"\n"))
self.assertIn(b'"event":"result"', payload)
self.assertIn(b'"result":{"success":true', payload)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,141 @@
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
class PropertyPerformanceInstrumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = AmesimHeliumPengRobinsonMedium()
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def tearDown(self) -> None:
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
enthalpy = self.medium.specific_enthalpy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
self.assertEqual(second, first)
record_iterations.assert_called_once()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "temperature_from_pressure_enthalpy")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_state_recovery_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
volume = 0.057
mass = self.medium.density(pressure, temperature) * volume
internal_energy = mass * self.medium.specific_internal_energy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.properties_from_mU(mass, internal_energy, volume)
second = self.medium.properties_from_mU(mass, internal_energy, volume)
self.assertIs(second, first)
record_iterations.assert_called_once()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "properties_from_mU")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_temperature_iteration_limit_is_reported_as_not_converged(self) -> None:
pressure = 123_456.0
base_temperature = 300.0
enthalpy = self.medium.specific_enthalpy(base_temperature)
fake_fluid = Mock()
fake_fluid.residual_specific_enthalpy.side_effect = (
lambda _pressure, temperature: (
self.medium.cp_ref * 10.0
if temperature >= base_temperature
else -self.medium.cp_ref * 10.0
)
)
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
record_iterations.assert_called_once_with(
"temperature_from_pressure_enthalpy",
16,
False,
)
def test_state_recovery_iteration_limit_is_reported_as_not_converged(self) -> None:
base_temperature = 300.0
target_internal_energy = self.medium.specific_internal_energy(
base_temperature
)
fake_fluid = Mock()
fake_fluid.residual_specific_internal_energy_at_density.side_effect = (
lambda temperature, _density: (
self.medium.cv * 10.0
if temperature >= base_temperature
else -self.medium.cv * 10.0
)
)
fake_fluid.pressure_from_density.return_value = 101_325.0
fake_fluid.residual_specific_enthalpy.return_value = 0.0
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.properties_from_mU(
1.0,
target_internal_energy,
1.0,
)
record_iterations.assert_called_once_with(
"properties_from_mU",
16,
False,
)
if __name__ == "__main__":
unittest.main()
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from functools import lru_cache
import importlib
import os
import unittest
from unittest.mock import patch
import app.simulation.performance as performance
@contextmanager
def profiling_mode(mode: str):
previous = os.environ.get("SIMULATIONAPP_PROFILE")
os.environ["SIMULATIONAPP_PROFILE"] = mode
module = importlib.reload(performance)
try:
yield module
finally:
if previous is None:
os.environ.pop("SIMULATIONAPP_PROFILE", None)
else:
os.environ["SIMULATIONAPP_PROFILE"] = previous
importlib.reload(performance)
class SimulationPerformanceTests(unittest.TestCase):
def test_off_mode_decorators_return_original_callables(self) -> None:
with profiling_mode("off") as module:
def phase_function(value: int) -> int:
return value + 1
def property_function(value: int) -> int:
return value * 2
self.assertIs(
module.profile_phase("phase")(phase_function),
phase_function,
)
self.assertIs(
module.profile_phase("phase", minimum_mode="off")(phase_function),
phase_function,
)
self.assertIs(
module.profile_property("property")(property_function),
property_function,
)
self.assertIs(
module.profile_property(
"property",
minimum_mode="off",
)(property_function),
property_function,
)
with module.profile_run() as trace:
self.assertEqual(phase_function(2), 3)
self.assertEqual(
trace.snapshot(),
{
"mode": "off",
"phases": {},
"properties": {},
"propertyOutermostNs": 0,
},
)
def test_standard_mode_records_nested_inclusive_and_self_time(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("inner")
def inner() -> None:
return None
@module.profile_phase("outer")
def outer() -> None:
inner()
clock = iter((0, 10, 20, 30, 50, 80))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
outer()
snapshot = trace.snapshot()
self.assertEqual(
snapshot["phases"]["inner"],
{
"calls": 1,
"inclusiveNs": 10,
"selfNs": 10,
"maxNs": 10,
"errors": 0,
},
)
self.assertEqual(snapshot["phases"]["outer"]["inclusiveNs"], 40)
self.assertEqual(snapshot["phases"]["outer"]["selfNs"], 30)
self.assertEqual(
snapshot["phases"]["simulation.total"]["inclusiveNs"],
80,
)
self.assertEqual(
snapshot["phases"]["simulation.total"]["selfNs"],
40,
)
def test_errors_are_recorded_and_propagated(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("explode")
def explode() -> None:
raise RuntimeError("expected")
with self.assertRaisesRegex(RuntimeError, "expected"):
with module.profile_run() as trace:
explode()
snapshot = trace.snapshot()
self.assertEqual(snapshot["phases"]["explode"]["errors"], 1)
self.assertEqual(
snapshot["phases"]["simulation.total"]["errors"],
1,
)
def test_manual_performance_span_uses_the_current_trace(self) -> None:
with profiling_mode("standard") as module:
clock = iter((0, 10, 20, 30))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
with module.performance_span("manual"):
pass
snapshot = trace.snapshot()
self.assertEqual(snapshot["phases"]["manual"]["inclusiveNs"], 10)
self.assertEqual(snapshot["phases"]["simulation.total"]["selfNs"], 20)
def test_nested_properties_only_add_outermost_time_once(self) -> None:
with profiling_mode("standard") as module:
class TestMedium:
name = "TestMedium"
@module.profile_property("inner", minimum_mode="standard")
def inner(self) -> float:
return 1.0
@module.profile_property("outer", minimum_mode="standard")
def outer(self) -> float:
return self.inner()
medium = TestMedium()
clock = iter((0, 10, 20, 30, 50, 80))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
self.assertEqual(medium.outer(), 1.0)
snapshot = trace.snapshot()
outer = snapshot["properties"]["semantic.TestMedium.outer"]
inner = snapshot["properties"]["semantic.TestMedium.inner"]
self.assertEqual(outer["inclusiveNs"], 40)
self.assertEqual(outer["selfNs"], 30)
self.assertEqual(inner["inclusiveNs"], 10)
self.assertEqual(snapshot["propertyOutermostNs"], 40)
def test_audit_resets_exact_input_shadow_and_records_iterations_and_cache(self) -> None:
with profiling_mode("audit") as module:
class TestMedium:
name = "AuditMedium"
@module.profile_property("inverse", track_cache=True)
@lru_cache(maxsize=4)
def inverse(self, value: float) -> float:
module.record_property_iterations(
"inverse",
3 if value == 1.0 else 4,
value == 1.0,
)
return value * 2.0
medium = TestMedium()
@module.profile_phase("closure", reset_property_shadow=True)
def closure() -> None:
medium.inverse(1.0)
medium.inverse(1.0)
medium.inverse(2.0)
with module.profile_run() as trace:
closure()
closure()
metric = trace.snapshot()["properties"][
"semantic.AuditMedium.inverse"
]
self.assertEqual(metric["calls"], 6)
self.assertEqual(metric["exactInputUnique"], 4)
self.assertEqual(metric["exactInputRepeats"], 2)
self.assertEqual(metric["iterationCalls"], 2)
self.assertEqual(metric["iterationTotal"], 7)
self.assertEqual(metric["iterationMax"], 4)
self.assertEqual(metric["iterationConverged"], 1)
self.assertEqual(metric["iterationNonconverged"], 1)
self.assertEqual(metric["cacheLookups"], 6)
self.assertEqual(metric["cacheHits"], 4)
self.assertEqual(metric["cacheMisses"], 2)
self.assertTrue(callable(medium.inverse.cache_clear))
self.assertTrue(callable(medium.inverse.cache_info))
self.assertTrue(callable(medium.inverse.cache_parameters))
medium.inverse.cache_clear()
self.assertEqual(medium.inverse.cache_info().currsize, 0)
def test_audit_minimum_decorator_is_absent_in_standard_mode(self) -> None:
with profiling_mode("standard") as module:
def kernel(value: float) -> float:
return value
self.assertIs(
module.profile_property(
"kernel",
layer="kernel",
minimum_mode="audit",
)(kernel),
kernel,
)
def test_async_profile_runs_are_context_isolated(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("work")
async def work() -> None:
await asyncio.sleep(0)
async def one_run() -> dict[str, object]:
with module.profile_run() as trace:
await work()
return trace.snapshot()
async def exercise() -> list[dict[str, object]]:
return await asyncio.gather(one_run(), one_run())
snapshots = asyncio.run(exercise())
self.assertEqual(
[snapshot["phases"]["work"]["calls"] for snapshot in snapshots],
[1, 1],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import textwrap
import unittest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPerformancePipelineTests(unittest.TestCase):
def test_off_mode_does_not_change_result_diagnostics(self) -> None:
script = textwrap.dedent(
"""
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print("performance" in result["diagnostics"])
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(completed.stdout.strip(), "False")
def test_standard_mode_reports_coarse_pipeline_phases_per_run(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
xml = build_reactflow_system_xml(amesim_pnl00r_project())
snapshots = []
for _ in range(2):
result = run_system_xml_simulation(xml)
assert result["success"], result["message"]
performance = result["diagnostics"]["performance"]
snapshots.append(
{
"mode": performance["mode"],
"calls": {
name: metrics["calls"]
for name, metrics in performance["phases"].items()
},
"propertyCount": len(performance["properties"]),
}
)
print(json.dumps(snapshots))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "standard"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshots = json.loads(completed.stdout)
required_phases = {
"simulation.total",
"simulation.xml_validation",
"simulation.network_compilation",
"simulation.system_construction",
"simulation.sample_initialization",
"simulation.integration",
"simulation.postprocessing",
"simulation.result_assembly",
"simulation.response_assembly",
}
audit_only_phases = {
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
}
self.assertEqual(len(snapshots), 2)
for snapshot in snapshots:
self.assertEqual(snapshot["mode"], "standard")
self.assertTrue(required_phases.issubset(snapshot["calls"]))
self.assertEqual(snapshot["calls"]["simulation.total"], 1)
self.assertEqual(snapshot["calls"]["simulation.xml_validation"], 1)
self.assertEqual(snapshot["calls"]["simulation.network_compilation"], 1)
self.assertEqual(snapshot["calls"]["simulation.integration"], 1)
self.assertEqual(snapshot["calls"]["simulation.postprocessing"], 1)
self.assertEqual(snapshot["calls"]["simulation.result_assembly"], 1)
self.assertEqual(snapshot["calls"]["simulation.response_assembly"], 1)
self.assertTrue(audit_only_phases.isdisjoint(snapshot["calls"]))
self.assertEqual(snapshot["propertyCount"], 0)
def test_audit_mode_reports_hot_phases_and_property_metrics(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print(json.dumps(result["diagnostics"]["performance"]))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshot = json.loads(completed.stdout)
for phase in (
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
):
self.assertGreater(snapshot["phases"][phase]["calls"], 0)
self.assertGreater(len(snapshot["properties"]), 0)
self.assertGreater(snapshot["propertyOutermostNs"], 0)
if __name__ == "__main__":
unittest.main()