build(tools): update mfem build and packaging tools
This commit is contained in:
@@ -87,6 +87,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--cc-launcher", action="append", default=[])
|
parser.add_argument("--cc-launcher", action="append", default=[])
|
||||||
parser.add_argument("--cxx-launcher", action="append", default=[])
|
parser.add_argument("--cxx-launcher", action="append", default=[])
|
||||||
parser.add_argument("--buildtype", required=True)
|
parser.add_argument("--buildtype", required=True)
|
||||||
|
parser.add_argument("--optimization", choices=("plain", "0", "g", "1", "2", "3", "s"), required=True)
|
||||||
|
parser.add_argument("--debug", choices=("true", "false"), required=True)
|
||||||
|
parser.add_argument("--ndebug", choices=("true", "false"), required=True)
|
||||||
|
parser.add_argument("--compiler-syntax", choices=("gcc", "msvc"), required=True)
|
||||||
|
parser.add_argument("--compiler-id", default="")
|
||||||
parser.add_argument("--jobs", type=int, required=True)
|
parser.add_argument("--jobs", type=int, required=True)
|
||||||
parser.add_argument("--host-system", required=True)
|
parser.add_argument("--host-system", required=True)
|
||||||
parser.add_argument("--cross-build", choices=("true", "false"), required=True)
|
parser.add_argument("--cross-build", choices=("true", "false"), required=True)
|
||||||
@@ -288,6 +293,32 @@ def compiler_flags(build_type: str) -> str:
|
|||||||
}[build_type]
|
}[build_type]
|
||||||
|
|
||||||
|
|
||||||
|
def mfem_compile_flags(args: argparse.Namespace) -> str:
|
||||||
|
"""Use Meson's effective options, not CMake's implicit -O3 -DNDEBUG.
|
||||||
|
|
||||||
|
MFEM_DEBUG is separately selected by CMAKE_BUILD_TYPE. In particular,
|
||||||
|
debugoptimized retains symbols without enabling MFEM's expensive checks.
|
||||||
|
"""
|
||||||
|
if args.compiler_syntax == "msvc":
|
||||||
|
flags = {
|
||||||
|
"plain": [], "0": ["/Od"], "g": [], "1": ["/O1"],
|
||||||
|
"2": ["/O2"], "3": ["/O2", "/Gw"], "s": ["/O1", "/Gw"],
|
||||||
|
}[args.optimization].copy()
|
||||||
|
if args.debug == "true":
|
||||||
|
flags.append("/Zi")
|
||||||
|
if args.ndebug == "true":
|
||||||
|
flags.append("/DNDEBUG")
|
||||||
|
else:
|
||||||
|
flags = [] if args.optimization == "plain" else ["-O" + args.optimization]
|
||||||
|
if args.optimization == "s" and args.compiler_id in {"clang", "apple-clang", "emscripten"}:
|
||||||
|
flags = ["-Oz"]
|
||||||
|
if args.debug == "true":
|
||||||
|
flags.append("-g")
|
||||||
|
if args.ndebug == "true":
|
||||||
|
flags.append("-DNDEBUG")
|
||||||
|
return " ".join(flags)
|
||||||
|
|
||||||
|
|
||||||
def joined_flags(values: Sequence[str]) -> str:
|
def joined_flags(values: Sequence[str]) -> str:
|
||||||
return " ".join(shlex.quote(value) for value in values)
|
return " ".join(shlex.quote(value) for value in values)
|
||||||
|
|
||||||
@@ -1411,6 +1442,8 @@ def build_mfem(
|
|||||||
options = common_cmake_args(
|
options = common_cmake_args(
|
||||||
args, build_type, c_compiler=args.cc, cxx_compiler=args.cxx
|
args, build_type, c_compiler=args.cc, cxx_compiler=args.cxx
|
||||||
) + [
|
) + [
|
||||||
|
f"-DCMAKE_C_FLAGS_{build_type.upper()}={mfem_compile_flags(args)}",
|
||||||
|
f"-DCMAKE_CXX_FLAGS_{build_type.upper()}={mfem_compile_flags(args)}",
|
||||||
"-DCMAKE_FIND_PACKAGE_PREFER_CONFIG=OFF",
|
"-DCMAKE_FIND_PACKAGE_PREFER_CONFIG=OFF",
|
||||||
"-DMFEM_ENABLE_TESTING=OFF",
|
"-DMFEM_ENABLE_TESTING=OFF",
|
||||||
"-DMFEM_ENABLE_EXAMPLES=OFF",
|
"-DMFEM_ENABLE_EXAMPLES=OFF",
|
||||||
@@ -1606,6 +1639,14 @@ def validate_mfem_feature_header(
|
|||||||
if not header.is_file():
|
if not header.is_file():
|
||||||
raise RuntimeError("MFEM feature validation failed: _config.hpp is missing")
|
raise RuntimeError("MFEM feature validation failed: _config.hpp is missing")
|
||||||
text = header.read_text(encoding="utf-8")
|
text = header.read_text(encoding="utf-8")
|
||||||
|
debug_defined = re.search(
|
||||||
|
r"^\s*#\s*define\s+MFEM_DEBUG(?:\s|$)", text, flags=re.MULTILINE
|
||||||
|
) is not None
|
||||||
|
if debug_defined != (cmake_build_type(args.buildtype) == "Debug"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"MFEM feature validation failed: MFEM_DEBUG disagrees with "
|
||||||
|
f"Meson buildtype={args.buildtype}; rebuild the MFEM bundle"
|
||||||
|
)
|
||||||
for feature_name, macro in CMAKE_FEATURES.items():
|
for feature_name, macro in CMAKE_FEATURES.items():
|
||||||
is_defined = re.search(
|
is_defined = re.search(
|
||||||
rf"^\s*#\s*define\s+{re.escape(macro)}(?:\s|$)",
|
rf"^\s*#\s*define\s+{re.escape(macro)}(?:\s|$)",
|
||||||
@@ -2432,6 +2473,7 @@ def main() -> int:
|
|||||||
if config_file.is_file():
|
if config_file.is_file():
|
||||||
old = json.loads(config_file.read_text(encoding="utf-8"))
|
old = json.loads(config_file.read_text(encoding="utf-8"))
|
||||||
if old.get("fingerprint") == fingerprint and existing_libraries:
|
if old.get("fingerprint") == fingerprint and existing_libraries:
|
||||||
|
validate_mfem_feature_header(args, features)
|
||||||
if args.stamp:
|
if args.stamp:
|
||||||
write_text_atomic(args.stamp, fingerprint + "\n")
|
write_text_atomic(args.stamp, fingerprint + "\n")
|
||||||
print("[mfem-bundle] configuration unchanged; reusing private prefix", flush=True)
|
print("[mfem-bundle] configuration unchanged; reusing private prefix", flush=True)
|
||||||
@@ -2586,6 +2628,8 @@ def main() -> int:
|
|||||||
"features": features,
|
"features": features,
|
||||||
"compiler": compiler_identity,
|
"compiler": compiler_identity,
|
||||||
"build_type": build_type,
|
"build_type": build_type,
|
||||||
|
"mfem_compile_flags": mfem_compile_flags(args),
|
||||||
|
"mfem_debug": build_type == "Debug",
|
||||||
"prefix": os.fspath(args.prefix),
|
"prefix": os.fspath(args.prefix),
|
||||||
}
|
}
|
||||||
write_text_atomic(config_file, json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
write_text_atomic(config_file, json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
||||||
|
|||||||
171
tools/read_serif_archive.py
Normal file
171
tools/read_serif_archive.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Inspect SERiF archive data without compiling SERiF or installing dependencies.
|
||||||
|
|
||||||
|
This is a data reader, not a physics/model factory. The C++ loader additionally
|
||||||
|
checks SERiF version tolerance, component schemas and discretization compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(data):
|
||||||
|
"""Match the archive's versioned corruption-detection fingerprint."""
|
||||||
|
value = 14695981039346656037
|
||||||
|
for byte in data:
|
||||||
|
value = ((value ^ byte) * 1099511628211) & ((1 << 64) - 1)
|
||||||
|
return f"fnv1a64-v1:{value:016x}"
|
||||||
|
|
||||||
|
|
||||||
|
def unquote(data):
|
||||||
|
"""Decode std::quoted text, whose escapes differ from JSON escapes."""
|
||||||
|
if len(data) < 2 or data[0] != 34 or data[-1] != 34:
|
||||||
|
raise ValueError("Invalid quoted string")
|
||||||
|
result = bytearray()
|
||||||
|
index = 1
|
||||||
|
while index < len(data) - 1:
|
||||||
|
if data[index] == 92:
|
||||||
|
index += 1
|
||||||
|
if index >= len(data) - 1:
|
||||||
|
raise ValueError("Unfinished string escape")
|
||||||
|
elif data[index] == 34:
|
||||||
|
raise ValueError("Unescaped quote inside string")
|
||||||
|
result.append(data[index])
|
||||||
|
index += 1
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
class Archive:
|
||||||
|
"""Read the ledger once and seek directly to individual data sections."""
|
||||||
|
|
||||||
|
def __init__(self, path, maximum_bytes=1024 * 1024 * 1024):
|
||||||
|
self.file = open(path, "rb")
|
||||||
|
self.maximum_bytes = maximum_bytes
|
||||||
|
try:
|
||||||
|
self.file.seek(0, 2)
|
||||||
|
size = self.file.tell()
|
||||||
|
self.file.seek(0)
|
||||||
|
header = self.file.read(512).decode("ascii")
|
||||||
|
lines = header.split("END_HEADER\n", 1)[0].splitlines()
|
||||||
|
if not lines or lines[0] != "SERIF_ARCHIVE 1":
|
||||||
|
raise ValueError("Unsupported SERiF archive format")
|
||||||
|
self.header = dict(shlex.split(line) for line in lines[1:])
|
||||||
|
offset = int(self.header["ledger_offset"])
|
||||||
|
count = int(self.header["ledger_bytes"])
|
||||||
|
if (offset != 512 or count < 0 or count > min(maximum_bytes, 64 * 1024 * 1024)
|
||||||
|
or offset + count > size or int(self.header["file_bytes"]) != size
|
||||||
|
or int(self.header["data_offset"]) != offset + count):
|
||||||
|
raise ValueError("Invalid archive section bounds")
|
||||||
|
ledger = self.file.read(count)
|
||||||
|
if fingerprint(ledger) != self.header["ledger_checksum"]:
|
||||||
|
raise ValueError("Damaged archive ledger")
|
||||||
|
self.entries = {}
|
||||||
|
previous_end = offset + count
|
||||||
|
previous_path = ""
|
||||||
|
for line in ledger.decode("utf-8").splitlines()[1:]:
|
||||||
|
path, schema, version, kind, extent, start, length, checksum = shlex.split(line)
|
||||||
|
entry = dict(schema=schema, version=int(version), kind=int(kind), count=int(extent),
|
||||||
|
offset=int(start), bytes=int(length), checksum=checksum)
|
||||||
|
if path <= previous_path or not 0 <= entry["kind"] <= 8 or entry["count"] < 0:
|
||||||
|
raise ValueError("Invalid or duplicate ledger entry")
|
||||||
|
if entry["kind"]:
|
||||||
|
if (entry["offset"] < previous_end or entry["bytes"] < 0
|
||||||
|
or entry["bytes"] > maximum_bytes or entry["offset"] + entry["bytes"] > size):
|
||||||
|
raise ValueError("Invalid or overlapping payload bounds")
|
||||||
|
previous_end = entry["offset"] + entry["bytes"]
|
||||||
|
elif entry["offset"] or entry["bytes"] or entry["count"]:
|
||||||
|
raise ValueError("Directory contains a payload")
|
||||||
|
self.entries[path] = entry
|
||||||
|
previous_path = path
|
||||||
|
if len(self.entries) != int(self.header["entries"]):
|
||||||
|
raise ValueError("Incorrect ledger extent")
|
||||||
|
if self.header["format"] not in ("binary", "text"):
|
||||||
|
raise ValueError("Unknown numeric encoding")
|
||||||
|
except Exception:
|
||||||
|
self.file.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Allow a with-statement to own the open file."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
"""Close the reader after analysis, including when decoding fails."""
|
||||||
|
self.file.close()
|
||||||
|
|
||||||
|
def read(self, path):
|
||||||
|
"""Return one string, number or numerical array without reading sibling fields."""
|
||||||
|
entry = self.entries[path]
|
||||||
|
kind, count = entry["kind"], entry["count"]
|
||||||
|
self.file.seek(entry["offset"])
|
||||||
|
data = self.file.read(entry["bytes"])
|
||||||
|
if len(data) != entry["bytes"] or fingerprint(data) != entry["checksum"]:
|
||||||
|
raise ValueError(f"Damaged payload: {path}")
|
||||||
|
binary = self.header["format"] == "binary"
|
||||||
|
if kind == 0:
|
||||||
|
return None
|
||||||
|
if kind == 1:
|
||||||
|
allowed = (b"\x00", b"\x01") if binary else (b"0", b"1")
|
||||||
|
if count != 1 or data not in allowed:
|
||||||
|
raise ValueError("Invalid boolean")
|
||||||
|
return data == allowed[1]
|
||||||
|
if kind == 5:
|
||||||
|
text = data if binary else unquote(data)
|
||||||
|
if len(text) != count:
|
||||||
|
raise ValueError("Invalid string length")
|
||||||
|
return text.decode("utf-8", errors="surrogateescape")
|
||||||
|
if kind in (2, 3, 4) and count != 1:
|
||||||
|
raise ValueError("Invalid scalar extent")
|
||||||
|
if count > len(data) or count > self.maximum_bytes // 8:
|
||||||
|
raise ValueError("Invalid numerical extent")
|
||||||
|
code = {2: "q", 3: "Q", 4: "d", 6: "d", 7: "q", 8: "Q"}[kind]
|
||||||
|
if binary:
|
||||||
|
if len(data) != 8 * count:
|
||||||
|
raise ValueError("Invalid binary array length")
|
||||||
|
values = list(struct.unpack(f"<{count}{code}", data))
|
||||||
|
else:
|
||||||
|
tokens = data.decode("ascii").split()
|
||||||
|
if len(tokens) != count:
|
||||||
|
raise ValueError("Invalid text array length")
|
||||||
|
values = []
|
||||||
|
for token in tokens:
|
||||||
|
if code == "d":
|
||||||
|
value = struct.unpack("<d", bytes.fromhex(token[5:])[::-1])[0] if token.startswith("bits:") else float(token)
|
||||||
|
else:
|
||||||
|
value = int(token)
|
||||||
|
if not (-(1 << 63) if code == "q" else 0) <= value < (1 << (63 if code == "q" else 64)):
|
||||||
|
raise ValueError("Integer out of range")
|
||||||
|
values.append(value)
|
||||||
|
return values[0] if kind in (2, 3, 4) else values
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Print available data, or export one numerical field for ordinary astronomical analysis."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("archive")
|
||||||
|
parser.add_argument("--read", metavar="FIELD", help="Read one ledger path")
|
||||||
|
parser.add_argument("--csv", action="store_true", help="Emit index,value rows for the selected numerical array")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
with Archive(arguments.archive) as archive:
|
||||||
|
if arguments.read:
|
||||||
|
value = archive.read(arguments.read)
|
||||||
|
if arguments.csv:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
parser.error("--csv requires an array field")
|
||||||
|
output = csv.writer(sys.stdout)
|
||||||
|
output.writerow(("index", "value"))
|
||||||
|
output.writerows(enumerate(value))
|
||||||
|
else:
|
||||||
|
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"SERiF {archive.header['serif_version']}; written {archive.header['written_utc']}")
|
||||||
|
for path, entry in archive.entries.items():
|
||||||
|
print(f"{path}\t{entry['schema']}\t{entry['count']}\t{entry['offset']}\t{entry['bytes']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import datetime as dt
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import platform
|
|
||||||
import random
|
|
||||||
import shutil
|
|
||||||
import statistics
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
JSON_PREFIX = "MFEM_BENCHMARK_JSON "
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Case:
|
|
||||||
name: str
|
|
||||||
device: str
|
|
||||||
ranks: int
|
|
||||||
threads: int
|
|
||||||
|
|
||||||
|
|
||||||
def positive_int(value: str) -> int:
|
|
||||||
parsed = int(value)
|
|
||||||
if parsed < 1:
|
|
||||||
raise argparse.ArgumentTypeError("must be at least one")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def integer_list(value: str) -> list[int]:
|
|
||||||
try:
|
|
||||||
parsed = [int(item.strip()) for item in value.split(",") if item.strip()]
|
|
||||||
except ValueError as error:
|
|
||||||
raise argparse.ArgumentTypeError("expected comma-separated integers") from error
|
|
||||||
if not parsed or any(item < 1 for item in parsed):
|
|
||||||
raise argparse.ArgumentTypeError("all entries must be positive")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def command_output(command: list[str]) -> str | None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
|
||||||
return None
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
output = result.stdout.strip()
|
|
||||||
return output if output else None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_program(value: str) -> str | None:
|
|
||||||
candidate = pathlib.Path(value)
|
|
||||||
if candidate.is_file():
|
|
||||||
return str(candidate.resolve())
|
|
||||||
discovered = shutil.which(value)
|
|
||||||
return str(pathlib.Path(discovered).resolve()) if discovered else None
|
|
||||||
|
|
||||||
|
|
||||||
def compact_cpu_list(cpus: list[int]) -> str:
|
|
||||||
if not cpus:
|
|
||||||
return ""
|
|
||||||
groups: list[str] = []
|
|
||||||
start = previous = cpus[0]
|
|
||||||
for current in cpus[1:]:
|
|
||||||
if current == previous + 1:
|
|
||||||
previous = current
|
|
||||||
continue
|
|
||||||
groups.append(str(start) if start == previous else f"{start}-{previous}")
|
|
||||||
start = previous = current
|
|
||||||
groups.append(str(start) if start == previous else f"{start}-{previous}")
|
|
||||||
return ",".join(groups)
|
|
||||||
|
|
||||||
|
|
||||||
def affinity_prefix(case: Case, setting: str) -> tuple[list[str], str | None]:
|
|
||||||
if setting == "none" or shutil.which("taskset") is None:
|
|
||||||
return [], None
|
|
||||||
if setting != "auto":
|
|
||||||
return ["taskset", "-c", setting], setting
|
|
||||||
if not hasattr(os, "sched_getaffinity"):
|
|
||||||
return [], None
|
|
||||||
available = sorted(os.sched_getaffinity(0))
|
|
||||||
count = min(len(available), max(case.ranks, case.threads))
|
|
||||||
chosen = compact_cpu_list(available[:count])
|
|
||||||
return (["taskset", "-c", chosen] if chosen else []), chosen or None
|
|
||||||
|
|
||||||
|
|
||||||
def expand_cases(args: argparse.Namespace) -> list[Case]:
|
|
||||||
requested = [item.strip() for item in args.cases.split(",") if item.strip()]
|
|
||||||
valid = {"cpu", "ceed-cpu", "omp", "mpi", "cuda", "ceed-cuda"}
|
|
||||||
unknown = sorted(set(requested) - valid)
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"unknown benchmark case(s): {', '.join(unknown)}")
|
|
||||||
cases: list[Case] = []
|
|
||||||
for requested_case in requested:
|
|
||||||
if requested_case == "cpu":
|
|
||||||
cases.append(Case("cpu-1r-1t", "cpu", 1, 1))
|
|
||||||
elif requested_case == "ceed-cpu":
|
|
||||||
cases.append(Case("ceed-cpu-1r-1t", args.ceed_cpu_device, 1, 1))
|
|
||||||
elif requested_case == "omp":
|
|
||||||
cases.extend(
|
|
||||||
Case(f"omp-1r-{threads}t", "omp", 1, threads)
|
|
||||||
for threads in args.omp_threads
|
|
||||||
)
|
|
||||||
elif requested_case == "mpi":
|
|
||||||
cases.extend(
|
|
||||||
Case(f"mpi-{ranks}r-1t", "cpu", ranks, 1)
|
|
||||||
for ranks in args.mpi_ranks
|
|
||||||
)
|
|
||||||
elif requested_case == "cuda":
|
|
||||||
cases.append(Case("cuda-1r-1t", "cuda", 1, 1))
|
|
||||||
elif requested_case == "ceed-cuda":
|
|
||||||
cases.append(Case("ceed-cuda-1r-1t", args.ceed_cuda_device, 1, 1))
|
|
||||||
deduplicated: list[Case] = []
|
|
||||||
for case in cases:
|
|
||||||
if case not in deduplicated:
|
|
||||||
deduplicated.append(case)
|
|
||||||
return deduplicated
|
|
||||||
|
|
||||||
|
|
||||||
def run_case(
|
|
||||||
args: argparse.Namespace, case: Case, trial: int, warmup_trial: bool
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
prefix, affinity = affinity_prefix(case, args.affinity)
|
|
||||||
command = prefix + [args.launcher, "-n", str(case.ranks)]
|
|
||||||
command.extend(args.launcher_arg)
|
|
||||||
command.extend(
|
|
||||||
[
|
|
||||||
args.executable,
|
|
||||||
"--device",
|
|
||||||
case.device,
|
|
||||||
"--mesh-n",
|
|
||||||
str(args.mesh_n),
|
|
||||||
"--order",
|
|
||||||
str(args.order),
|
|
||||||
"--applications",
|
|
||||||
str(args.applications),
|
|
||||||
"--max-applications",
|
|
||||||
str(args.max_applications),
|
|
||||||
"--minimum-apply-seconds",
|
|
||||||
str(args.minimum_apply_seconds),
|
|
||||||
"--warmup-applications",
|
|
||||||
str(args.warmup_applications),
|
|
||||||
"--relative-tolerance",
|
|
||||||
str(args.relative_tolerance),
|
|
||||||
"--max-iterations",
|
|
||||||
str(args.max_iterations),
|
|
||||||
"--trial",
|
|
||||||
str(trial),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if args.solve:
|
|
||||||
command.append("--solve")
|
|
||||||
environment = os.environ.copy()
|
|
||||||
environment.update(
|
|
||||||
{
|
|
||||||
"OMP_NUM_THREADS": str(case.threads),
|
|
||||||
"OMP_DYNAMIC": "FALSE",
|
|
||||||
"OMP_PROC_BIND": "close",
|
|
||||||
"OMP_PLACES": "cores",
|
|
||||||
"FI_PROVIDER": environment.get("FI_PROVIDER", "sockets"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
environment.setdefault("OMPI_ALLOW_RUN_AS_ROOT", "1")
|
|
||||||
environment.setdefault("OMPI_ALLOW_RUN_AS_ROOT_CONFIRM", "1")
|
|
||||||
selected_interface = args.network_interface
|
|
||||||
if selected_interface == "local":
|
|
||||||
selected_interface = "lo0" if sys.platform == "darwin" else "lo"
|
|
||||||
if selected_interface == "auto":
|
|
||||||
environment.pop("FI_SOCKETS_IFACE", None)
|
|
||||||
environment.pop("HYDRA_IFACE", None)
|
|
||||||
environment.pop("MPICH_INTERFACE_HOSTNAME", None)
|
|
||||||
else:
|
|
||||||
environment["FI_SOCKETS_IFACE"] = selected_interface
|
|
||||||
environment["HYDRA_IFACE"] = selected_interface
|
|
||||||
environment.pop("MPICH_INTERFACE_HOSTNAME", None)
|
|
||||||
if sys.platform == "darwin" and args.network_interface == "local":
|
|
||||||
environment["MPICH_INTERFACE_HOSTNAME"] = "127.0.0.1"
|
|
||||||
if case.device.startswith("cuda") or case.device.startswith("ceed-cuda"):
|
|
||||||
if args.gpu_aware_mpi:
|
|
||||||
environment["MFEM_GPU_AWARE_MPI"] = "1"
|
|
||||||
else:
|
|
||||||
environment.pop("MFEM_GPU_AWARE_MPI", None)
|
|
||||||
if args.cuda_visible_devices is not None:
|
|
||||||
environment["CUDA_VISIBLE_DEVICES"] = args.cuda_visible_devices
|
|
||||||
|
|
||||||
started = time.perf_counter()
|
|
||||||
completed = subprocess.run(
|
|
||||||
command,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=environment,
|
|
||||||
timeout=args.timeout,
|
|
||||||
)
|
|
||||||
wall_seconds = time.perf_counter() - started
|
|
||||||
combined_output = "\n".join((completed.stdout, completed.stderr))
|
|
||||||
records = [
|
|
||||||
json.loads(line[len(JSON_PREFIX) :])
|
|
||||||
for line in combined_output.splitlines()
|
|
||||||
if line.startswith(JSON_PREFIX)
|
|
||||||
]
|
|
||||||
if completed.returncode != 0 or len(records) != 1:
|
|
||||||
tail = "\n".join(combined_output.splitlines()[-30:])
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{case.name} trial {trial} failed with exit code "
|
|
||||||
f"{completed.returncode}; JSON records={len(records)}\n{tail}"
|
|
||||||
)
|
|
||||||
record = records[0]
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"case": case.name,
|
|
||||||
"requested_device": case.device,
|
|
||||||
"runner_trial": trial,
|
|
||||||
"discarded_warmup_trial": warmup_trial,
|
|
||||||
"wall_seconds": wall_seconds,
|
|
||||||
"cpu_affinity": affinity,
|
|
||||||
"network_interface": environment.get("HYDRA_IFACE"),
|
|
||||||
"cuda_visible_devices": environment.get("CUDA_VISIBLE_DEVICES"),
|
|
||||||
"gpu_aware_mpi": environment.get("MFEM_GPU_AWARE_MPI") == "1",
|
|
||||||
"command": command,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not record.get("valid", False):
|
|
||||||
raise RuntimeError(f"{case.name} trial {trial} failed numerical validation")
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def median_absolute_deviation(values: list[float]) -> float:
|
|
||||||
center = statistics.median(values)
|
|
||||||
return statistics.median(abs(value - center) for value in values)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_cross_backend(
|
|
||||||
records: list[dict[str, Any]], relative_tolerance: float
|
|
||||||
) -> dict[str, float]:
|
|
||||||
measured = [row for row in records if not row["discarded_warmup_trial"]]
|
|
||||||
baseline = [row for row in measured if row["case"] == "cpu-1r-1t"]
|
|
||||||
if not baseline:
|
|
||||||
return {}
|
|
||||||
validation_fields = ["probe_norm"]
|
|
||||||
if measured and all(row.get("solve_ran", False) for row in measured):
|
|
||||||
validation_fields.append("solution_norm")
|
|
||||||
references = {
|
|
||||||
field: statistics.median(float(row[field]) for row in baseline)
|
|
||||||
for field in validation_fields
|
|
||||||
}
|
|
||||||
maximum_errors = {field: 0.0 for field in references}
|
|
||||||
for row in measured:
|
|
||||||
for field, reference in references.items():
|
|
||||||
relative_error = abs(float(row[field]) - reference) / max(
|
|
||||||
abs(reference), 1.0e-300
|
|
||||||
)
|
|
||||||
maximum_errors[field] = max(maximum_errors[field], relative_error)
|
|
||||||
if relative_error > relative_tolerance:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{row['case']} disagrees with CPU for {field}: relative "
|
|
||||||
f"error {relative_error:.3e} > {relative_tolerance:.3e}"
|
|
||||||
)
|
|
||||||
return maximum_errors
|
|
||||||
|
|
||||||
|
|
||||||
def checkpoint_results(
|
|
||||||
output_dir: pathlib.Path,
|
|
||||||
metadata: dict[str, Any],
|
|
||||||
records: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
destination = output_dir / "partial-results.json"
|
|
||||||
temporary = output_dir / "partial-results.json.tmp"
|
|
||||||
temporary.write_text(
|
|
||||||
json.dumps({"schema_version": 1, "metadata": metadata, "raw": records}, indent=2)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
temporary.replace(destination)
|
|
||||||
|
|
||||||
|
|
||||||
def summarize(cases: list[Case], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
summaries: list[dict[str, Any]] = []
|
|
||||||
for case in cases:
|
|
||||||
rows = [
|
|
||||||
row
|
|
||||||
for row in records
|
|
||||||
if row["case"] == case.name and not row["discarded_warmup_trial"]
|
|
||||||
]
|
|
||||||
if not rows:
|
|
||||||
continue
|
|
||||||
throughput = [float(row["apply_mdof_per_second"]) for row in rows]
|
|
||||||
apply_seconds = [float(row["apply_seconds"]) for row in rows]
|
|
||||||
solve_seconds = [float(row["solve_seconds"]) for row in rows]
|
|
||||||
total_seconds = [float(row["total_seconds"]) for row in rows]
|
|
||||||
timed_applications = [int(row["applications"]) for row in rows]
|
|
||||||
summaries.append(
|
|
||||||
{
|
|
||||||
"case": case.name,
|
|
||||||
"device": case.device,
|
|
||||||
"ranks": case.ranks,
|
|
||||||
"threads": case.threads,
|
|
||||||
"trials": len(rows),
|
|
||||||
"global_true_dofs": rows[0]["global_true_dofs"],
|
|
||||||
"requested_applications": rows[0]["requested_applications"],
|
|
||||||
"applications_min": min(timed_applications),
|
|
||||||
"applications_max": max(timed_applications),
|
|
||||||
"apply_mdof_per_second_median": statistics.median(throughput),
|
|
||||||
"apply_mdof_per_second_mad": median_absolute_deviation(throughput),
|
|
||||||
"apply_mdof_per_second_min": min(throughput),
|
|
||||||
"apply_mdof_per_second_max": max(throughput),
|
|
||||||
"apply_seconds_median": statistics.median(apply_seconds),
|
|
||||||
"solve_seconds_median": statistics.median(solve_seconds),
|
|
||||||
"total_seconds_median": statistics.median(total_seconds),
|
|
||||||
"cg_iterations": sorted({row["cg_iterations"] for row in rows}),
|
|
||||||
"solve_ran": all(bool(row["solve_ran"]) for row in rows),
|
|
||||||
"verified_relative_residual_max": max(
|
|
||||||
float(row["verified_relative_residual"]) for row in rows
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
baseline = next((row for row in summaries if row["case"] == "cpu-1r-1t"), None)
|
|
||||||
baseline_rate = (
|
|
||||||
float(baseline["apply_mdof_per_second_median"]) if baseline else None
|
|
||||||
)
|
|
||||||
for row in summaries:
|
|
||||||
row["speedup_vs_cpu"] = (
|
|
||||||
float(row["apply_mdof_per_second_median"]) / baseline_rate
|
|
||||||
if baseline_rate
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return summaries
|
|
||||||
|
|
||||||
|
|
||||||
def markdown_table(summaries: list[dict[str, Any]]) -> str:
|
|
||||||
lines = [
|
|
||||||
"| Case | Device | Ranks | Threads | Timed apps | Apply window (s) | Apply MDoF/s, median [range] | MAD | Speedup | Solve (s) | CG iters |",
|
|
||||||
"|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
|
||||||
]
|
|
||||||
for row in summaries:
|
|
||||||
speedup = row["speedup_vs_cpu"]
|
|
||||||
speedup_text = f"{speedup:.2f}x" if speedup is not None else "n/a"
|
|
||||||
solve_text = f"{row['solve_seconds_median']:.4f}" if row["solve_ran"] else "—"
|
|
||||||
iteration_text = (
|
|
||||||
",".join(str(value) for value in row["cg_iterations"])
|
|
||||||
if row["solve_ran"]
|
|
||||||
else "—"
|
|
||||||
)
|
|
||||||
applications_text = (
|
|
||||||
str(row["applications_min"])
|
|
||||||
if row["applications_min"] == row["applications_max"]
|
|
||||||
else f"{row['applications_min']}-{row['applications_max']}"
|
|
||||||
)
|
|
||||||
lines.append(
|
|
||||||
f"| {row['case']} | `{row['device']}` | {row['ranks']} | "
|
|
||||||
f"{row['threads']} | {applications_text} | "
|
|
||||||
f"{row['apply_seconds_median']:.3f} | "
|
|
||||||
f"{row['apply_mdof_per_second_median']:.2f} "
|
|
||||||
f"[{row['apply_mdof_per_second_min']:.2f}, "
|
|
||||||
f"{row['apply_mdof_per_second_max']:.2f}] | "
|
|
||||||
f"{row['apply_mdof_per_second_mad']:.2f} | "
|
|
||||||
f"{speedup_text} | {solve_text} | {iteration_text} |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def collect_metadata(args: argparse.Namespace) -> dict[str, Any]:
|
|
||||||
executable = pathlib.Path(args.executable).resolve()
|
|
||||||
source_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
return {
|
|
||||||
"timestamp_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
||||||
"hostname": platform.node(),
|
|
||||||
"platform": platform.platform(),
|
|
||||||
"python": sys.version,
|
|
||||||
"cpu_count": os.cpu_count(),
|
|
||||||
"lscpu": command_output(["lscpu"]) if shutil.which("lscpu") else None,
|
|
||||||
"executable": str(executable),
|
|
||||||
"launcher": str(pathlib.Path(args.launcher).resolve()),
|
|
||||||
"launcher_version": command_output([args.launcher, "--version"]),
|
|
||||||
"compiler": command_output(["c++", "--version"]),
|
|
||||||
"nvidia_smi": command_output(
|
|
||||||
[
|
|
||||||
"nvidia-smi",
|
|
||||||
"--query-gpu=name,driver_version,temperature.gpu,pstate,clocks.sm,power.draw",
|
|
||||||
"--format=csv,noheader",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if shutil.which("nvidia-smi")
|
|
||||||
else None,
|
|
||||||
"git_commit": command_output(
|
|
||||||
["git", "-C", str(source_root), "rev-parse", "HEAD"]
|
|
||||||
),
|
|
||||||
"git_dirty": bool(
|
|
||||||
command_output(["git", "-C", str(source_root), "status", "--porcelain"])
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"mesh_n": args.mesh_n,
|
|
||||||
"order": args.order,
|
|
||||||
"applications": args.applications,
|
|
||||||
"max_applications": args.max_applications,
|
|
||||||
"minimum_apply_seconds": args.minimum_apply_seconds,
|
|
||||||
"warmup_applications": args.warmup_applications,
|
|
||||||
"trials": args.trials,
|
|
||||||
"discarded_warmup_trials": args.warmup_trials,
|
|
||||||
"relative_tolerance": args.relative_tolerance,
|
|
||||||
"max_iterations": args.max_iterations,
|
|
||||||
"solve": args.solve,
|
|
||||||
"affinity": args.affinity,
|
|
||||||
"network_interface": args.network_interface,
|
|
||||||
"cuda_visible_devices": args.cuda_visible_devices,
|
|
||||||
"gpu_aware_mpi": args.gpu_aware_mpi,
|
|
||||||
"cross_backend_relative_tolerance": args.cross_backend_relative_tolerance,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def write_results(
|
|
||||||
output_dir: pathlib.Path,
|
|
||||||
metadata: dict[str, Any],
|
|
||||||
records: list[dict[str, Any]],
|
|
||||||
summaries: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(output_dir / "results.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{"schema_version": 1, "metadata": metadata, "raw": records, "summary": summaries},
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
fieldnames = [
|
|
||||||
"case",
|
|
||||||
"device",
|
|
||||||
"ranks",
|
|
||||||
"threads",
|
|
||||||
"trials",
|
|
||||||
"global_true_dofs",
|
|
||||||
"requested_applications",
|
|
||||||
"applications_min",
|
|
||||||
"applications_max",
|
|
||||||
"apply_mdof_per_second_median",
|
|
||||||
"apply_mdof_per_second_mad",
|
|
||||||
"apply_mdof_per_second_min",
|
|
||||||
"apply_mdof_per_second_max",
|
|
||||||
"speedup_vs_cpu",
|
|
||||||
"apply_seconds_median",
|
|
||||||
"solve_seconds_median",
|
|
||||||
"solve_ran",
|
|
||||||
"total_seconds_median",
|
|
||||||
"cg_iterations",
|
|
||||||
"verified_relative_residual_max",
|
|
||||||
]
|
|
||||||
with (output_dir / "results.csv").open("w", newline="", encoding="utf-8") as handle:
|
|
||||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
||||||
writer.writeheader()
|
|
||||||
for summary in summaries:
|
|
||||||
writer.writerow(summary)
|
|
||||||
first = records[0]
|
|
||||||
if first["minimum_apply_seconds"] > 0.0:
|
|
||||||
timing_description = (
|
|
||||||
"Application counts were selected with a "
|
|
||||||
f"{first['minimum_apply_seconds']:.3g}-second calibration window, then "
|
|
||||||
"measured in a fresh timing window."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
timing_description = (
|
|
||||||
"The requested application count was measured directly in one timing "
|
|
||||||
"window."
|
|
||||||
)
|
|
||||||
report = "# MFEM backend benchmark\n\n"
|
|
||||||
report += (
|
|
||||||
f"Host: `{metadata['hostname']}` \n"
|
|
||||||
f"UTC timestamp: `{metadata['timestamp_utc']}` \n"
|
|
||||||
f"Workload: {first['global_elements']:,} 3D hex elements, H1 order "
|
|
||||||
f"{first['order']}, {first['global_true_dofs']:,} global true DoFs, "
|
|
||||||
f"at least {first['requested_applications']} timed operator applications "
|
|
||||||
f"per trial. {timing_description}\n\n"
|
|
||||||
)
|
|
||||||
report += markdown_table(summaries)
|
|
||||||
report += (
|
|
||||||
"\nThe table reports medians from fresh processes. Operator throughput uses "
|
|
||||||
"the same global partial-assembly diffusion operator and true-DOF field in "
|
|
||||||
"every case. MPI timings use the slowest rank.\n"
|
|
||||||
)
|
|
||||||
(output_dir / "results.md").write_text(report, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--executable", required=True)
|
|
||||||
parser.add_argument("--launcher", required=True)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cases",
|
|
||||||
default="cpu,ceed-cpu,omp,mpi,cuda,ceed-cuda",
|
|
||||||
help="Comma-separated subset of cpu, ceed-cpu, omp, mpi, cuda, ceed-cuda.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--omp-threads", type=integer_list, default=[8])
|
|
||||||
parser.add_argument("--mpi-ranks", type=integer_list, default=[4])
|
|
||||||
parser.add_argument("--trials", type=positive_int, default=5)
|
|
||||||
parser.add_argument("--warmup-trials", type=int, default=0)
|
|
||||||
parser.add_argument("--mesh-n", type=positive_int, default=48)
|
|
||||||
parser.add_argument("--order", type=positive_int, default=3)
|
|
||||||
parser.add_argument("--applications", type=positive_int, default=50)
|
|
||||||
parser.add_argument("--max-applications", type=positive_int, default=1_000_000)
|
|
||||||
parser.add_argument(
|
|
||||||
"--minimum-apply-seconds",
|
|
||||||
type=float,
|
|
||||||
default=1.0,
|
|
||||||
help=(
|
|
||||||
"Double the requested applications until a calibration window is this "
|
|
||||||
"long, then measure a fresh window."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument("--warmup-applications", type=int, default=5)
|
|
||||||
parser.add_argument("--relative-tolerance", type=float, default=1.0e-6)
|
|
||||||
parser.add_argument("--max-iterations", type=positive_int, default=1000)
|
|
||||||
parser.add_argument(
|
|
||||||
"--solve",
|
|
||||||
action="store_true",
|
|
||||||
help="Also run and time the Jacobi-preconditioned CG validation solve.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--timeout", type=positive_int, default=900)
|
|
||||||
parser.add_argument(
|
|
||||||
"--affinity",
|
|
||||||
default="auto",
|
|
||||||
help="CPU list for taskset, 'auto' for a compact set, or 'none'.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--network-interface",
|
|
||||||
default="local",
|
|
||||||
help="'local' for platform loopback, 'auto' for routing, or an interface name.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cuda-visible-devices",
|
|
||||||
default=None,
|
|
||||||
help="Optional CUDA_VISIBLE_DEVICES value for GPU cases.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--gpu-aware-mpi",
|
|
||||||
action="store_true",
|
|
||||||
help="Opt in only when the selected MPI implementation is CUDA-aware.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cross-backend-relative-tolerance",
|
|
||||||
type=float,
|
|
||||||
default=1.0e-5,
|
|
||||||
help="Maximum relative difference from CPU for probe and solution norms.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--ceed-cpu-device",
|
|
||||||
default="ceed-cpu",
|
|
||||||
help="Full libCEED CPU resource or portable ceed-cpu alias.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--ceed-cuda-device",
|
|
||||||
default="ceed-cuda",
|
|
||||||
help="Full libCEED CUDA resource or portable ceed-cuda alias.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--launcher-arg",
|
|
||||||
action="append",
|
|
||||||
default=[],
|
|
||||||
help="Additional MPI launcher argument; repeat as needed.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output-dir",
|
|
||||||
type=pathlib.Path,
|
|
||||||
default=pathlib.Path("benchmark-results"),
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.warmup_trials < 0 or args.warmup_applications < 0:
|
|
||||||
parser.error("warm-up counts may not be negative")
|
|
||||||
if args.relative_tolerance <= 0.0 or args.cross_backend_relative_tolerance <= 0.0:
|
|
||||||
parser.error("tolerances must be positive")
|
|
||||||
if args.minimum_apply_seconds < 0.0:
|
|
||||||
parser.error("minimum apply seconds may not be negative")
|
|
||||||
if args.max_applications < args.applications:
|
|
||||||
parser.error("max applications must be at least the requested applications")
|
|
||||||
for attribute in ("executable", "launcher"):
|
|
||||||
value = getattr(args, attribute)
|
|
||||||
resolved = resolve_program(value)
|
|
||||||
if resolved is None:
|
|
||||||
parser.error(f"program not found: {value}")
|
|
||||||
setattr(args, attribute, resolved)
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_arguments()
|
|
||||||
try:
|
|
||||||
cases = expand_cases(args)
|
|
||||||
except ValueError as error:
|
|
||||||
print(f"error: {error}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
metadata = collect_metadata(args)
|
|
||||||
records: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for warmup in range(args.warmup_trials):
|
|
||||||
for case in cases:
|
|
||||||
print(f"warm-up {warmup + 1}/{args.warmup_trials}: {case.name}", flush=True)
|
|
||||||
records.append(run_case(args, case, -(warmup + 1), True))
|
|
||||||
checkpoint_results(args.output_dir, metadata, records)
|
|
||||||
|
|
||||||
schedule = [(trial, case) for trial in range(1, args.trials + 1) for case in cases]
|
|
||||||
random.Random(20260905).shuffle(schedule)
|
|
||||||
for index, (trial, case) in enumerate(schedule, start=1):
|
|
||||||
print(
|
|
||||||
f"run {index}/{len(schedule)}: {case.name}, trial {trial}/{args.trials}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
records.append(run_case(args, case, trial, False))
|
|
||||||
checkpoint_results(args.output_dir, metadata, records)
|
|
||||||
|
|
||||||
summaries = summarize(cases, records)
|
|
||||||
workload_signatures = {
|
|
||||||
(
|
|
||||||
row["global_elements"],
|
|
||||||
row["global_true_dofs"],
|
|
||||||
row["mesh_n"],
|
|
||||||
row["order"],
|
|
||||||
row["requested_applications"],
|
|
||||||
row["minimum_apply_seconds"],
|
|
||||||
)
|
|
||||||
for row in records
|
|
||||||
if not row["discarded_warmup_trial"]
|
|
||||||
}
|
|
||||||
if len(workload_signatures) != 1:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"cases did not use one fixed global workload: {workload_signatures}"
|
|
||||||
)
|
|
||||||
metadata["cross_backend_maximum_relative_errors"] = validate_cross_backend(
|
|
||||||
records, args.cross_backend_relative_tolerance
|
|
||||||
)
|
|
||||||
if shutil.which("nvidia-smi"):
|
|
||||||
metadata["nvidia_smi_after"] = command_output(
|
|
||||||
[
|
|
||||||
"nvidia-smi",
|
|
||||||
"--query-gpu=name,driver_version,temperature.gpu,pstate,clocks.sm,power.draw",
|
|
||||||
"--format=csv,noheader",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
write_results(args.output_dir, metadata, records, summaries)
|
|
||||||
table = markdown_table(summaries)
|
|
||||||
print("\n" + table)
|
|
||||||
print(f"JSON, CSV, and Markdown written to {args.output_dir.resolve()}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
raise SystemExit(main())
|
|
||||||
except (OSError, subprocess.TimeoutExpired, RuntimeError) as error:
|
|
||||||
print(f"benchmark failed: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run one MPI test without baking a particular MPI launcher into the meson build tree."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--launcher", required=True)
|
|
||||||
parser.add_argument("--processes", type=int, default=2)
|
|
||||||
parser.add_argument("executable")
|
|
||||||
parser.add_argument("arguments", nargs="*")
|
|
||||||
args = parser.parse_args()
|
|
||||||
command = [args.launcher, "-n", str(args.processes), args.executable, *args.arguments]
|
|
||||||
environment = dict(os.environ)
|
|
||||||
environment.setdefault("OMPI_ALLOW_RUN_AS_ROOT", "1")
|
|
||||||
environment.setdefault("OMPI_ALLOW_RUN_AS_ROOT_CONFIRM", "1")
|
|
||||||
loopback = "lo0" if sys.platform == "darwin" else "lo"
|
|
||||||
environment.setdefault("FI_PROVIDER", "sockets")
|
|
||||||
environment.setdefault("FI_SOCKETS_IFACE", loopback)
|
|
||||||
environment.setdefault("HYDRA_IFACE", loopback)
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
environment.setdefault("MPICH_INTERFACE_HOSTNAME", "127.0.0.1")
|
|
||||||
return subprocess.run(command, env=environment, check=False).returncode
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
211
tools/test_mfem_build_policy.py
Normal file
211
tools/test_mfem_build_policy.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""MFEM build-policy regressions; no MFEM sources or downloaded dependencies needed.
|
||||||
|
|
||||||
|
Run with ``python tools/test_mfem_build_policy.py``. The integration test compiles
|
||||||
|
a tiny C/C++ fixture with local CMake; the cache test never launches a compiler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import build_mfem_bundle as bundle
|
||||||
|
|
||||||
|
|
||||||
|
FEATURES = dict.fromkeys(bundle.CMAKE_FEATURES, False)
|
||||||
|
CMAKE = shutil.which("cmake")
|
||||||
|
CC = shutil.which("cc")
|
||||||
|
CXX = shutil.which("c++")
|
||||||
|
|
||||||
|
|
||||||
|
def arguments(root, mode="release", optimization="2", debug=False, ndebug=False):
|
||||||
|
return [
|
||||||
|
"build_mfem_bundle.py",
|
||||||
|
"--source", str(root / "source"),
|
||||||
|
"--work-dir", str(root / "bundle" / "work"),
|
||||||
|
"--prefix", str(root / "prefix"),
|
||||||
|
"--cmake", CMAKE or "cmake",
|
||||||
|
"--cc", CC or "cc", "--cxx", CXX or "c++",
|
||||||
|
"--buildtype", mode, "--optimization", optimization,
|
||||||
|
"--debug", str(debug).lower(), "--ndebug", str(ndebug).lower(),
|
||||||
|
"--compiler-syntax", "gcc", "--compiler-id", "clang",
|
||||||
|
"--jobs", "1", "--host-system", platform.system().lower(),
|
||||||
|
"--cross-build", "false", "--library-kind", "static",
|
||||||
|
"--allow-system", "true", "--vendor-dependency-prefix", "false",
|
||||||
|
*[item for name in FEATURES for item in ("--feature", name + "=OFF")],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse(root, **kwargs):
|
||||||
|
with patch.object(sys, "argv", arguments(root, **kwargs)):
|
||||||
|
return bundle.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def public_header(prefix, debug=False):
|
||||||
|
header = prefix / "include" / "mfem" / "config" / "_config.hpp"
|
||||||
|
header.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
header.write_text("#define MFEM_USE_DOUBLE\n" +
|
||||||
|
("#define MFEM_DEBUG\n" if debug else ""))
|
||||||
|
return header
|
||||||
|
|
||||||
|
|
||||||
|
class MfemBuildPolicyTests(unittest.TestCase):
|
||||||
|
def test_assertions_symbols_and_optimization_are_independent(self):
|
||||||
|
args = SimpleNamespace(compiler_syntax="gcc", compiler_id="clang",
|
||||||
|
optimization="2", debug="false", ndebug="false")
|
||||||
|
self.assertEqual(shlex.split(bundle.mfem_compile_flags(args)), ["-O2"])
|
||||||
|
args.debug = "true"
|
||||||
|
self.assertNotIn("-DNDEBUG", shlex.split(bundle.mfem_compile_flags(args)))
|
||||||
|
self.assertIn("-g", shlex.split(bundle.mfem_compile_flags(args)))
|
||||||
|
args.optimization = "s"
|
||||||
|
self.assertIn("-Oz", shlex.split(bundle.mfem_compile_flags(args)))
|
||||||
|
args.compiler_id = "gcc"
|
||||||
|
self.assertIn("-Os", shlex.split(bundle.mfem_compile_flags(args)))
|
||||||
|
args.optimization = "plain"
|
||||||
|
args.debug = "false"
|
||||||
|
self.assertEqual(bundle.mfem_compile_flags(args), "")
|
||||||
|
args.compiler_syntax = "msvc"
|
||||||
|
args.optimization = "2"
|
||||||
|
args.ndebug = "true"
|
||||||
|
self.assertEqual(shlex.split(bundle.mfem_compile_flags(args)),
|
||||||
|
["/O2", "/DNDEBUG"])
|
||||||
|
|
||||||
|
def test_public_header_checks_mfem_debug_in_both_directions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for mode, debug in [("debug", True), ("release", False),
|
||||||
|
("debugoptimized", False)]:
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
args = parse(root, mode=mode)
|
||||||
|
public_header(args.prefix, debug)
|
||||||
|
bundle.validate_mfem_feature_header(args, FEATURES)
|
||||||
|
public_header(args.prefix, not debug)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "MFEM_DEBUG"):
|
||||||
|
bundle.validate_mfem_feature_header(args, FEATURES)
|
||||||
|
|
||||||
|
def test_cache_reconfiguration_and_cache_hit_header_validation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "CMakeLists.txt").write_text("# Stable source fingerprint\n")
|
||||||
|
|
||||||
|
def fake_install(args, features, env, build_type, mpicc, mpicxx):
|
||||||
|
public_header(args.prefix, build_type == "Debug")
|
||||||
|
(args.prefix / "include" / "mfem.hpp").write_text("// fixture\n")
|
||||||
|
(args.prefix / "lib").mkdir(parents=True, exist_ok=True)
|
||||||
|
(args.prefix / "lib" / "libmfem.a").write_bytes(b"fixture archive")
|
||||||
|
|
||||||
|
with patch.object(bundle, "output", return_value="fixture compiler"), \
|
||||||
|
patch.object(bundle, "build_mfem", side_effect=fake_install) as build, \
|
||||||
|
patch.object(bundle, "install_licenses"), \
|
||||||
|
patch.object(bundle, "sanitize_installed_metadata"), \
|
||||||
|
contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
def invoke(**kwargs):
|
||||||
|
with patch.object(sys, "argv", arguments(root, **kwargs)):
|
||||||
|
return bundle.main()
|
||||||
|
|
||||||
|
self.assertEqual(invoke(mode="debug", optimization="0", debug=True), 0)
|
||||||
|
self.assertEqual(invoke(), 0) # Same directory; release invalidates debug.
|
||||||
|
self.assertEqual(build.call_count, 2)
|
||||||
|
self.assertEqual(invoke(), 0) # Unchanged valid prefix is reusable.
|
||||||
|
self.assertEqual(build.call_count, 2)
|
||||||
|
header = public_header(root / "prefix", debug=True)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "MFEM_DEBUG"):
|
||||||
|
invoke() # Same fingerprint must not hide an incompatible header.
|
||||||
|
self.assertEqual(build.call_count, 2)
|
||||||
|
header.unlink()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "_config.hpp.*missing"):
|
||||||
|
invoke()
|
||||||
|
|
||||||
|
@unittest.skipUnless(CMAKE and CC and CXX and os.name != "nt",
|
||||||
|
"requires local CMake and GCC-compatible C/C++ compilers")
|
||||||
|
def test_real_cmake_reconfigure_replaces_implicit_flags_for_both_languages(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "CMakeLists.txt").write_text('''\
|
||||||
|
cmake_minimum_required(VERSION 3.18)
|
||||||
|
project(mfem_policy_fixture LANGUAGES C CXX)
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
# Match the upstream MFEM build-type rule, including debugoptimized behavior.
|
||||||
|
if(CMAKE_BUILD_TYPE MATCHES "Debug|debug|DEBUG")
|
||||||
|
set(MFEM_DEBUG ON)
|
||||||
|
else()
|
||||||
|
set(MFEM_DEBUG OFF)
|
||||||
|
endif()
|
||||||
|
configure_file(config.hpp.in mfem_config.hpp)
|
||||||
|
add_executable(policy_probe probe.cpp probe.c)
|
||||||
|
target_include_directories(policy_probe PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}")
|
||||||
|
install(TARGETS policy_probe DESTINATION bin)
|
||||||
|
''')
|
||||||
|
(source / "config.hpp.in").write_text("#cmakedefine MFEM_DEBUG\n")
|
||||||
|
checks = '''\
|
||||||
|
#include "expected.hpp"
|
||||||
|
#include "mfem_config.hpp"
|
||||||
|
#if defined(NDEBUG) != EXPECT_NDEBUG
|
||||||
|
#error CMake changed the requested assertion policy
|
||||||
|
#endif
|
||||||
|
#if defined(MFEM_DEBUG) != EXPECT_MFEM_DEBUG
|
||||||
|
#error Stale MFEM debug configuration
|
||||||
|
#endif
|
||||||
|
#if defined(__OPTIMIZE__) != EXPECT_OPTIMIZED
|
||||||
|
#error Requested optimization was not applied
|
||||||
|
#endif
|
||||||
|
'''
|
||||||
|
(source / "probe.c").write_text(checks + "int c_probe(void) { return 17; }\n")
|
||||||
|
(source / "probe.cpp").write_text(checks + '''\
|
||||||
|
extern "C" int c_probe(void);
|
||||||
|
int main() { return c_probe() == 17 ? 0 : 1; }
|
||||||
|
''')
|
||||||
|
|
||||||
|
def quiet_run(command, *, cwd=None, env=None):
|
||||||
|
result = subprocess.run([os.fspath(x) for x in command], cwd=cwd,
|
||||||
|
env=env, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, text=True, timeout=20)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout)
|
||||||
|
|
||||||
|
cases = [("debug", "0", True, False),
|
||||||
|
("release", "3", False, False),
|
||||||
|
("release", "2", False, False),
|
||||||
|
("release", "2", False, True),
|
||||||
|
("debugoptimized", "2", True, False),
|
||||||
|
("debug", "0", True, False)]
|
||||||
|
with patch.object(bundle, "prepare_mfem_source", return_value=source), \
|
||||||
|
patch.object(bundle, "run", side_effect=quiet_run):
|
||||||
|
for mode, optimization, debug, ndebug in cases:
|
||||||
|
with self.subTest(mode=mode, ndebug=ndebug):
|
||||||
|
args = parse(root, mode=mode, optimization=optimization,
|
||||||
|
debug=debug, ndebug=ndebug)
|
||||||
|
(source / "expected.hpp").write_text(
|
||||||
|
f"#define EXPECT_NDEBUG {int(ndebug)}\n"
|
||||||
|
f"#define EXPECT_MFEM_DEBUG {int(mode == 'debug')}\n"
|
||||||
|
f"#define EXPECT_OPTIMIZED {int(optimization != '0')}\n")
|
||||||
|
bundle.build_mfem(args, FEATURES, os.environ.copy(),
|
||||||
|
bundle.cmake_build_type(mode), "", "")
|
||||||
|
build = args.work_dir / "mfem"
|
||||||
|
commands = json.loads((build / "compile_commands.json").read_text())
|
||||||
|
self.assertEqual({Path(x["file"]).suffix for x in commands}, {".c", ".cpp"})
|
||||||
|
for command in commands:
|
||||||
|
tokens = shlex.split(command["command"])
|
||||||
|
self.assertEqual([token for token in tokens
|
||||||
|
if token.startswith("-O")],
|
||||||
|
["-O" + optimization])
|
||||||
|
self.assertEqual("-DNDEBUG" in tokens, ndebug)
|
||||||
|
self.assertEqual("-g" in tokens, debug)
|
||||||
|
quiet_run([args.prefix / "bin" / "policy_probe"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
335
tools/validate_ester.py
Normal file
335
tools/validate_ester.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run fresh SERiF solves and compare the unchanged physical requests with the archived ESTER study.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python tools/validate_ester.py --report ../MeanField/reports/ester_validation_2026-09-21 \
|
||||||
|
--executable build/tests/solver/ester_validation --output /tmp/serif-ester
|
||||||
|
|
||||||
|
Omit --executable to analyze already generated SERiF output. No archived solver
|
||||||
|
field is used as a new measurement. Archive coefficients are warm initial guesses;
|
||||||
|
solve.json must demonstrate independent SERiF nonlinear convergence.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
CASES = ("n1_J0", "n1_J0p05", "n1_J0p1", "n1p5_J0", "n1p625_J0")
|
||||||
|
SCALARS = {"rho": "rho", "h": "h", "P": "pressure_h", "Phi": "phi", "bernoulli_defect": "bernoulli_defect"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_rows(path):
|
||||||
|
"""Retain archived point order and names, including failed point locations."""
|
||||||
|
with path.open() as source:
|
||||||
|
return list(csv.DictReader(source))
|
||||||
|
|
||||||
|
|
||||||
|
def column(rows, name):
|
||||||
|
"""Read one named numerical measurement from every requested point."""
|
||||||
|
return np.asarray([float(row[name]) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
def magnitude(values):
|
||||||
|
"""Use absolute scalar values or Euclidean vector lengths for error norms."""
|
||||||
|
return np.linalg.norm(values, axis=1) if values.ndim == 2 else np.abs(values)
|
||||||
|
|
||||||
|
|
||||||
|
def norms(values, mask, scale, weights=None):
|
||||||
|
"""Equal-weight requested-grid norms, as specified by the original protocol."""
|
||||||
|
selected = magnitude(values[mask]) / scale
|
||||||
|
if not len(selected) or not np.isfinite(selected).all():
|
||||||
|
raise ValueError("A required physical comparison contains missing or nonfinite samples")
|
||||||
|
selected_weights = np.ones(len(selected)) if weights is None else weights[mask]
|
||||||
|
if not np.isfinite(selected_weights).all() or np.any(selected_weights <= 0):
|
||||||
|
raise ValueError("A required physical comparison has invalid quadrature weights")
|
||||||
|
return {"count": len(selected), "rms_scaled": float(np.sqrt(np.average(selected**2, weights=selected_weights))),
|
||||||
|
"max_scaled": float(np.max(selected))}
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
"""Record the exact bytes of a source or independently generated measurement."""
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def compare_surface(report, case, output, models):
|
||||||
|
"""Repeat the original actual-surface quadrature and sampled-axis measurements."""
|
||||||
|
old = read_rows(report / "meanfield_runs" / case / "surface.csv")
|
||||||
|
new = read_rows(output / "surface.csv")
|
||||||
|
identity = ("kind", "boundary_element", "sample", "reference_u", "reference_v")
|
||||||
|
if [[row[key] for key in identity[:3]] for row in old] != [[row[key] for key in identity[:3]] for row in new]:
|
||||||
|
raise ValueError(f"{case}: actual surface sample identities changed")
|
||||||
|
for key in identity[3:]:
|
||||||
|
if not np.array_equal(column(old, key), column(new, key)):
|
||||||
|
raise ValueError(f"{case}: actual surface reference requests changed")
|
||||||
|
base = models["base"]
|
||||||
|
scale = base.globals["R_eq"]
|
||||||
|
kind = np.asarray([row["kind"] for row in old])
|
||||||
|
old_radius, radius = column(old, "radius"), column(new, "radius")
|
||||||
|
xyz = np.stack([column(new, c) for c in ("x", "y", "z")], axis=1)
|
||||||
|
if not np.isfinite(xyz).all() or not np.isfinite(radius).all() or np.any(radius <= 0):
|
||||||
|
raise ValueError(f"{case}: a required actual surface sample is nonfinite or nonpositive")
|
||||||
|
if not np.allclose(np.linalg.norm(xyz, axis=1), radius, rtol=32*np.finfo(float).eps, atol=1e-15):
|
||||||
|
raise ValueError(f"{case}: actual surface positions and radii disagree")
|
||||||
|
reference = {level: model.surface_radius(column(new, "theta")) for level, model in models.items()}
|
||||||
|
old_reference = base.surface_radius(column(old, "theta"))
|
||||||
|
archived = json.loads((report / "analysis" / case / "metrics.json").read_text())["surface"]
|
||||||
|
quadrature = {}
|
||||||
|
for rule in ("q14", "q18"):
|
||||||
|
mask = kind == rule
|
||||||
|
fresh = norms(radius - reference["base"], mask, scale, column(new, "area_weight"))
|
||||||
|
reconstructed = norms(old_radius - old_reference, mask, scale, column(old, "area_weight"))
|
||||||
|
recorded = {key: archived[rule][key] for key in ("rms_scaled", "max_scaled")}
|
||||||
|
if fresh["count"] != archived[rule]["eligible"] or any(abs(reconstructed[key] - recorded[key]) > 128*np.finfo(float).eps for key in recorded):
|
||||||
|
raise ValueError(f"{case}: archived {rule} surface metric did not reproduce")
|
||||||
|
sensitivity = {level: norms(reference[level] - reference["base"], mask, scale, column(new, "area_weight")) for level in ("low", "high")}
|
||||||
|
quadrature[rule] = {"fresh": fresh, "archived": recorded, "archived_reconstructed": reconstructed,
|
||||||
|
"fresh_minus_archived": norms(radius - old_radius, mask, scale, column(old, "area_weight")),
|
||||||
|
"area": float(column(new, "area_weight")[mask].sum()), "archived_area": archived[rule]["area"],
|
||||||
|
"ester_grid_change": sensitivity,
|
||||||
|
"rms_change": fresh["rms_scaled"] - recorded["rms_scaled"], "max_change": fresh["max_scaled"] - recorded["max_scaled"],
|
||||||
|
"within_archived_plus_high_grid_change": all(fresh[key] <= recorded[key] + sensitivity["high"][key] for key in recorded)}
|
||||||
|
# These are selected actual nodes, never extrema or ideal harmonic radii.
|
||||||
|
axis_tolerance = 2e-12 * scale
|
||||||
|
old_xyz = np.stack([column(old, c) for c in ("x", "y", "z")], axis=1)
|
||||||
|
axes = {}
|
||||||
|
# Freeze the original node visits and exact-coordinate deduplication. Repeating that
|
||||||
|
# deduplication on fresh floating-point positions can change weights through roundoff alone.
|
||||||
|
for name, select in {"equator": np.abs(old_xyz[:, 2]) <= axis_tolerance,
|
||||||
|
"north_pole": (np.hypot(old_xyz[:, 0], old_xyz[:, 1]) <= axis_tolerance) & (old_xyz[:, 2] > 0),
|
||||||
|
"south_pole": (np.hypot(old_xyz[:, 0], old_xyz[:, 1]) <= axis_tolerance) & (old_xyz[:, 2] < 0)}.items():
|
||||||
|
indices = np.flatnonzero((kind == "node") & select)
|
||||||
|
if not len(indices):
|
||||||
|
raise ValueError(f"{case}: missing required {name} actual surface nodes")
|
||||||
|
_, unique = np.unique(old_xyz[indices], axis=0, return_index=True)
|
||||||
|
indices = indices[unique]
|
||||||
|
transverse = np.abs(xyz[indices, 2]) if name == "equator" else np.hypot(xyz[indices, 0], xyz[indices, 1])
|
||||||
|
if np.any(transverse > axis_tolerance):
|
||||||
|
raise ValueError(f"{case}: a required {name} surface request moved off its physical axis")
|
||||||
|
values = radius[indices]
|
||||||
|
axes[name] = {"mean": float(values.mean()), "minimum": float(values.min()), "maximum": float(values.max()),
|
||||||
|
"archived_node_visits": int(((kind == "node") & select).sum()), "fixed_archived_unique_coordinate_samples": len(values),
|
||||||
|
"coordinate_tolerance": axis_tolerance}
|
||||||
|
return {"quadrature": quadrature, "axis_samples": axes}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_globals(report, case, output, old, new, models, surface):
|
||||||
|
"""Compare measured center traces, typed global coefficients and actual surface nodes."""
|
||||||
|
center = [index for index, row in enumerate(old) if row["id"] == "center:00000"]
|
||||||
|
if len(center) != 1 or any(float(new[center[0]][c]) != 0 for c in ("x", "y", "z")):
|
||||||
|
raise ValueError(f"{case}: the explicit center trace is missing")
|
||||||
|
index = center[0]
|
||||||
|
summary = json.loads((output / "physical_summary.json").read_text())
|
||||||
|
measured = {name: summary[name] for name in ("Omega", "C", "surface_volume_radius_q14", "surface_volume_radius_q18")}
|
||||||
|
measured.update(rho_c=float(new[index]["rho"]), h_c=float(new[index]["h"]))
|
||||||
|
bulk_rows = read_rows(output / "bulk.csv")
|
||||||
|
bulk = {(row["quadrature"], row["metric"]): float(row["value"]) for row in bulk_rows}
|
||||||
|
if len(bulk) != len(bulk_rows):
|
||||||
|
raise ValueError(f"{case}: duplicate physical bulk measurements")
|
||||||
|
for quadrature in ("14", "18"):
|
||||||
|
for quantity, metric in {"M": "mass", "I": "moment_of_inertia_z", "J": "angular_momentum_z", "R_volume": "volume_radius"}.items():
|
||||||
|
measured[quantity + "_q" + quadrature] = bulk[(quadrature, metric)]
|
||||||
|
for axis, values in surface["axis_samples"].items():
|
||||||
|
quantity = ("R_eq" if axis == "equator" else "R_p") + "_sampled_" + axis + "_mean"
|
||||||
|
measured[quantity] = values["mean"]
|
||||||
|
records = {(row["quantity"], row["quadrature"]): row for row in read_rows(report / "analysis" / "global_comparison.csv") if row["case"] == case}
|
||||||
|
result = {}
|
||||||
|
for name, value in measured.items():
|
||||||
|
if name.startswith("surface_volume_radius_q"):
|
||||||
|
recorded = records[("R_volume", name.rsplit("q", 1)[1])]
|
||||||
|
quantity = "R_volume"
|
||||||
|
elif name.endswith(("_q14", "_q18")):
|
||||||
|
quantity, quadrature = name.rsplit("_q", 1)
|
||||||
|
recorded = records[(quantity, quadrature)]
|
||||||
|
else:
|
||||||
|
recorded = records[(name, "14" if name in ("Omega", "C") else "")]
|
||||||
|
quantity = name.split("_sampled_")[0]
|
||||||
|
scale = float(recorded["normalization"])
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise ValueError(f"{case}: {name} is nonfinite")
|
||||||
|
reference = {level: model.globals[quantity] for level, model in models.items()}
|
||||||
|
previous = float(recorded["meanfield"])
|
||||||
|
if any(abs(reference[level] - float(recorded["ester_" + level])) > 128*np.finfo(float).eps*max(1, abs(reference[level])) for level in reference):
|
||||||
|
raise ValueError(f"{case}: archived {name} ESTER global reference no longer reproduces")
|
||||||
|
fresh_error = (value - reference["base"]) / scale
|
||||||
|
old_error = (previous - reference["base"]) / scale
|
||||||
|
sensitivity = {level: (reference[level] - reference["base"]) / scale for level in ("low", "high")}
|
||||||
|
result[name] = {"fresh": value, "archived": previous, "ester": reference, "normalization": scale,
|
||||||
|
"fresh_scaled_difference": fresh_error, "archived_scaled_difference": old_error,
|
||||||
|
"fresh_minus_archived_scaled": (value - previous) / scale, "ester_grid_change_scaled": sensitivity,
|
||||||
|
"within_archived_plus_high_grid_change": bool(abs(fresh_error) <= abs(old_error) + abs(sensitivity["high"]))}
|
||||||
|
return {"quantities": result, "center_boundary_trace_possible": bool(float(new[index]["boundary_trace_possible"])),
|
||||||
|
"surface_volume_measurement": "Fresh divergence-theorem boundary quadrature compared with original volume-integral radii.",
|
||||||
|
"physical_summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
def compare(report, case, output, reference_class):
|
||||||
|
"""Keep the original common-material masks fixed so missing samples cannot improve a score."""
|
||||||
|
solve = json.loads((output / "solve.json").read_text())
|
||||||
|
if solve["case"] != case or solve["converged"] is not True or not math.isfinite(solve["final_residual_norm"]) or not 0.0 <= solve["final_residual_norm"] <= 1e-10:
|
||||||
|
raise ValueError(f"{case}: fresh SERiF nonlinear qualification failed")
|
||||||
|
metadata = dict(line.split("=", 1) for line in (report / "inputs" / case / "metadata.txt").read_text().splitlines() if "=" in line)
|
||||||
|
if digest(report / "inputs" / case / "mesh.smesh") != metadata["mesh_sha256"]:
|
||||||
|
raise ValueError(f"{case}: the reference mesh no longer matches its recorded hash")
|
||||||
|
catalog = json.loads((report / "reference_catalog.json").read_text())[case]
|
||||||
|
old = read_rows(report / "meanfield_runs" / case / "samples.csv")
|
||||||
|
new = read_rows(output / "samples.csv")
|
||||||
|
if [row["id"] for row in old] != [row["id"] for row in new]:
|
||||||
|
raise ValueError(f"{case}: point identity or order changed")
|
||||||
|
xyz = np.stack([column(new, c) for c in ("x", "y", "z")], axis=1)
|
||||||
|
if not np.array_equal(xyz, np.stack([column(old, c) for c in ("x", "y", "z")], axis=1)):
|
||||||
|
raise ValueError(f"{case}: requested physical coordinates changed")
|
||||||
|
old_located = column(old, "located") == 1
|
||||||
|
new_located = column(new, "located") == 1
|
||||||
|
if np.any(old_located & ~new_located):
|
||||||
|
raise ValueError(f"{case}: {np.count_nonzero(old_located & ~new_located)} previously located samples were lost")
|
||||||
|
def physical_coordinates(rows, located):
|
||||||
|
"""Validate actual located positions against their unchanged physical requests."""
|
||||||
|
mapped = np.stack([column(rows, "mapped_" + c) for c in ("x", "y", "z")], axis=1)
|
||||||
|
error = np.linalg.norm(mapped - xyz, axis=1)
|
||||||
|
tolerance = 2e-11 * np.maximum(1.0, np.linalg.norm(xyz, axis=1))
|
||||||
|
roundoff = 64 * np.finfo(float).eps * np.maximum(1.0, np.linalg.norm(xyz, axis=1))
|
||||||
|
if not np.isfinite(mapped[located]).all() or np.any(error[located] > tolerance[located] + roundoff[located]):
|
||||||
|
raise ValueError(f"{case}: sampled physical location exceeds the original locator tolerance")
|
||||||
|
if np.any(np.abs(error[located] - column(rows, "location_error")[located]) > roundoff[located]):
|
||||||
|
raise ValueError(f"{case}: sampled location error is inconsistent with mapped coordinates")
|
||||||
|
coordinates = xyz.copy()
|
||||||
|
coordinates[located] = mapped[located]
|
||||||
|
return coordinates
|
||||||
|
old_xyz = physical_coordinates(old, old_located)
|
||||||
|
new_xyz = physical_coordinates(new, new_located)
|
||||||
|
models = {level: reference_class(report / "ester_runs" / Path(catalog[level]["directory"]).name,
|
||||||
|
n=float(metadata["n"]), K=float(metadata["K"]), M=float(metadata["M"]), G=float(metadata["G"]))
|
||||||
|
for level in ("base", "low", "high")}
|
||||||
|
if not all(model.globals["reference_converged"] for model in models.values()):
|
||||||
|
raise ValueError(f"{case}: an ESTER reference did not meet its recorded convergence criterion")
|
||||||
|
match_tolerance = json.loads((report / "matching_plan.json").read_text())["angular_momentum_absolute_tolerance"]
|
||||||
|
if any(abs(model.globals["J"] - float(metadata["J"])) > match_tolerance + 16 * np.finfo(float).eps for model in models.values()):
|
||||||
|
raise ValueError(f"{case}: an ESTER reference does not match the physical angular momentum")
|
||||||
|
reference = {level: model.evaluate(new_xyz) for level, model in models.items()}
|
||||||
|
old_reference = models["base"].evaluate(old_xyz)
|
||||||
|
base = models["base"]
|
||||||
|
radii = np.linalg.norm(old_xyz, axis=1)
|
||||||
|
theta = np.arccos(np.clip(np.divide(old_xyz[:, 2], radii, out=np.ones(len(radii)), where=radii > 0), -1, 1))
|
||||||
|
common = old_located & (column(old, "stellar_material") == 1) & old_reference["inside_star"]
|
||||||
|
strict = common & (column(old, "boundary_trace_possible") == 0) & (radii / base.surface_radius(theta) < 1 - 1e-8)
|
||||||
|
if np.any(strict & (column(new, "stellar_material") != 1)):
|
||||||
|
raise ValueError(f"{case}: a required interior physical request changed material support")
|
||||||
|
if np.any(strict & (column(new, "boundary_trace_possible") != 0)):
|
||||||
|
raise ValueError(f"{case}: a required interior sample moved onto an ambiguous FE trace")
|
||||||
|
scales = {"rho": base.rho_c, "h": base.H, "P": base.rho_c * base.H / (base.n + 1),
|
||||||
|
"Phi": base.G * base.M / base.globals["R_eq"], "gradPhi": base.G * base.M / base.globals["R_eq"]**2,
|
||||||
|
"gradPhi_broken": base.G * base.M / base.globals["R_eq"]**2, "bernoulli_defect": base.H}
|
||||||
|
recorded_metrics = {(row["category"], row["region"], row["field"]): row
|
||||||
|
for row in read_rows(report / "analysis" / "metrics.csv") if row["case"] == case and row["group"] == "all"}
|
||||||
|
|
||||||
|
def field_values(rows, name):
|
||||||
|
"""Read the specified physical scalar or one of the two gravity gradients."""
|
||||||
|
if name in ("gradPhi", "gradPhi_broken"):
|
||||||
|
prefix = "mixed_dphi_d" if name == "gradPhi" else "broken_phi_d"
|
||||||
|
return np.stack([column(rows, prefix + c) for c in ("x", "y", "z")], axis=1)
|
||||||
|
return column(rows, SCALARS[name])
|
||||||
|
|
||||||
|
def field_comparison(name, mask, category, region="all"):
|
||||||
|
"""Reconstruct the exact archived metric before comparing a new independently sampled field."""
|
||||||
|
archived_name = "gradPhi_mixed" if name == "gradPhi" else name
|
||||||
|
reference_name = "gradPhi" if name == "gradPhi_broken" else name
|
||||||
|
recorded = recorded_metrics[(category, region, archived_name)]
|
||||||
|
if int(recorded["eligible"]) != int(mask.sum()):
|
||||||
|
raise ValueError(f"{case}: {category}/{region} support no longer reproduces the original study")
|
||||||
|
old_field, new_field = field_values(old, name), field_values(new, name)
|
||||||
|
fresh = norms(new_field - reference["base"][reference_name], mask, scales[name])
|
||||||
|
preserved = norms(new_field - old_field, mask, scales[name])
|
||||||
|
sensitivity = {level: norms(reference[level][reference_name] - reference["base"][reference_name], mask, scales[name]) for level in ("low", "high")}
|
||||||
|
archived = {key: float(recorded[key]) for key in ("rms_scaled", "max_scaled")}
|
||||||
|
reconstructed = norms(old_field - old_reference[reference_name], mask, scales[name])
|
||||||
|
if any(abs(reconstructed[key] - archived[key]) > 128 * np.finfo(float).eps * max(1.0, abs(archived[key]))
|
||||||
|
for key in ("rms_scaled", "max_scaled")):
|
||||||
|
raise ValueError(f"{case}: archived {category}/{region}/{name} metrics did not reproduce at their original mapped coordinates")
|
||||||
|
return {"fresh": fresh, "archived": archived, "archived_reconstructed": reconstructed,
|
||||||
|
"reference_relocation": norms(reference["base"][reference_name] - old_reference[reference_name], mask, scales[name]),
|
||||||
|
"fresh_minus_archived": preserved, "ester_grid_change": sensitivity,
|
||||||
|
"rms_change": fresh["rms_scaled"] - archived["rms_scaled"],
|
||||||
|
"max_change": fresh["max_scaled"] - archived["max_scaled"],
|
||||||
|
"within_archived_plus_high_grid_change": all(fresh[key] <= archived[key] + sensitivity["high"][key]
|
||||||
|
for key in ("rms_scaled", "max_scaled"))}
|
||||||
|
|
||||||
|
fields = {name: field_comparison(name, strict, "common_material_strict") for name in (*SCALARS, "gradPhi", "gradPhi_broken")}
|
||||||
|
# Preserve the original radial-region definitions, including gravity in physical vacuum.
|
||||||
|
fraction = radii / base.surface_radius(theta)
|
||||||
|
regions = {"all": old_located, "core": old_located & (fraction < .5),
|
||||||
|
"envelope": old_located & (fraction >= .5) & (fraction < .9),
|
||||||
|
"surface_layer": old_located & (fraction >= .9) & (fraction <= 1),
|
||||||
|
"outside_reference": old_located & (fraction > 1)}
|
||||||
|
gravity = {region: {name: field_comparison(name, mask, "located_all_materials", region) for name in ("Phi", "gradPhi", "gradPhi_broken")}
|
||||||
|
for region, mask in regions.items() if mask.any()}
|
||||||
|
def material_diagnostics(rows, located):
|
||||||
|
"""Expose negative material tails and undefined pressure without clipping them."""
|
||||||
|
material = located & (column(rows, "stellar_material") == 1)
|
||||||
|
density, enthalpy, pressure = (column(rows, name) for name in ("rho", "h", "pressure_h"))
|
||||||
|
if not np.isfinite(density[material]).all() or not np.isfinite(enthalpy[material]).all():
|
||||||
|
raise ValueError(f"{case}: material density or enthalpy contains a nonfinite value")
|
||||||
|
return {"material_points": int(material.sum()), "negative_density_points": int((material & (density < 0)).sum()),
|
||||||
|
"negative_enthalpy_points": int((material & (enthalpy < 0)).sum()),
|
||||||
|
"nonfinite_pressure_points": int((material & ~np.isfinite(pressure)).sum()),
|
||||||
|
"minimum_density": float(density[material].min()), "minimum_enthalpy": float(enthalpy[material].min())}
|
||||||
|
tails = {"archived": material_diagnostics(old, old_located), "fresh": material_diagnostics(new, new_located)}
|
||||||
|
surface = compare_surface(report, case, output, models)
|
||||||
|
globals_comparison = compare_globals(report, case, output, old, new, models, surface)
|
||||||
|
provenance = {str(path): digest(path) for path in [report / "inputs" / case / "accepted_state.txt",
|
||||||
|
report / "inputs" / case / "mesh.smesh", output / "accepted_state.txt", output / "samples.csv", report / "ester_reference.py",
|
||||||
|
report / "inputs" / case / "layout.csv", report / "inputs" / case / "metadata.txt",
|
||||||
|
output / "physical_summary.json", output / "surface.csv", output / "bulk.csv"]}
|
||||||
|
result = {"case": case, "fresh_solve": solve, "strict_common_material_points": int(strict.sum()),
|
||||||
|
"located_points": int(new_located.sum()), "fields": fields, "located_gravity_by_region": gravity, "surface": surface, "globals": globals_comparison, "material_tail_diagnostics": tails, "sha256": provenance,
|
||||||
|
"ester_reference_sha256": {level: model.hashes for level, model in models.items()},
|
||||||
|
"qualification": "Fresh nonlinear atol=1e-10; fixed archived physical requests, strict material masks and all-located gravity masks; no universal physical percentage tolerance exists in the source protocol.",
|
||||||
|
"envelope": "Archived error plus the separately measured high-minus-base ESTER grid change is reported, not substituted for the recorded errors."}
|
||||||
|
qualified = list(fields.values()) + [item for region in gravity.values() for item in region.values()]
|
||||||
|
qualified += list(surface["quadrature"].values()) + list(globals_comparison["quantities"].values())
|
||||||
|
result["within_archived_plus_high_grid_change"] = all(item["within_archived_plus_high_grid_change"] for item in qualified)
|
||||||
|
(output / "comparison.json").write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Optionally solve selected stars, then write their independently checked comparisons."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--report", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--executable", type=Path)
|
||||||
|
parser.add_argument("--cases", nargs="+", choices=CASES, default=CASES)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
specification = importlib.util.spec_from_file_location("serif_ester_reference", args.report / "ester_reference.py")
|
||||||
|
reference = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(reference)
|
||||||
|
results = []
|
||||||
|
for case in args.cases:
|
||||||
|
target = args.output / case
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.executable:
|
||||||
|
with (target / "solve.log").open("w") as log:
|
||||||
|
subprocess.run([str(args.executable.resolve()), str(args.report.resolve()), case, str(target.resolve())],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||||
|
result = compare(args.report, case, target, reference.Model)
|
||||||
|
results.append(result)
|
||||||
|
print(case, "converged", result["fresh_solve"]["final_residual_norm"], "Newton steps", result["fresh_solve"]["accepted_iterations"], flush=True)
|
||||||
|
for field, data in result["fields"].items():
|
||||||
|
print(f" {field}: RMS {data['fresh']['rms_scaled']:.9g}; archived {data['archived']['rms_scaled']:.9g}; max physical drift {data['fresh_minus_archived']['max_scaled']:.3g}", flush=True)
|
||||||
|
for rule, data in result["surface"]["quadrature"].items():
|
||||||
|
print(f" surface {rule}: RMS {data['fresh']['rms_scaled']:.9g}; archived {data['archived']['rms_scaled']:.9g}; max radius drift {data['fresh_minus_archived']['max_scaled']:.3g}", flush=True)
|
||||||
|
print(" all reported errors within archived plus measured ESTER high-grid change:",
|
||||||
|
result["within_archived_plus_high_grid_change"], flush=True)
|
||||||
|
(args.output / "comparison.json").write_text(json.dumps(results, indent=2, allow_nan=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Build and run all six sandboxes with each Linux compiler/library/linker pair.
|
# Build and run all sandboxes with each Linux compiler/library/linker pair.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|||||||
Reference in New Issue
Block a user