diff --git a/README.md b/README.md index 790315f6c..10825a358 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,9 @@ on x86-64 (win-64) and Windows on Arm (win-arm64). On any other platform the ins reports that it found no matching distribution: [Installation](https://solprop.readthedocs.io/en/stable/introduction/installation.html) explains that error and how to report the platform, and covers environment checks and a first run. For GPUs, build the -native backend from source (below). +native backend from source (below). Then run `solprop calibrate` once, to fit the native +backend's threads, waiting and transform plans to the machine +([Calibrating solprop to your machine](https://solprop.readthedocs.io/en/stable/guide/performance/calibration.html)). ## Building from source diff --git a/benchmarks/ab.py b/benchmarks/ab.py index f56eb2c02..65536d37a 100644 --- a/benchmarks/ab.py +++ b/benchmarks/ab.py @@ -26,20 +26,23 @@ ) from benchmarks.registry import select from benchmarks.runner import ( + calibrate_builds, freeze_harness, launch_worker, plan_rows, prepared_python, receipt, + worker_calibration, ) from benchmarks.system import eligible_cpus, guard, physical_cores, thread_environment -from benchmarks.track import BACKENDS, selection_options, steppers +from benchmarks.track import BACKENDS, calibration_option, selection_options, steppers __all__ = [ "ARMS", "ab", "ab_options", "arm_configuration", + "arm_environment", "arm_order", "field_difference", "show_summary", @@ -77,6 +80,28 @@ def ab_options(parser: argparse.ArgumentParser) -> None: choices=BACKENDS, help="the candidate arm's backend; default: --backend", ) + calibration_option(parser) + for arm in ARMS: + parser.add_argument( + f"--{arm}-env", + action="append", + default=[], + metavar="NAME=VALUE", + help=f"an environment variable for the {arm} arm alone, over the " + "threading preset (repeatable); SOLPROP_CALIBRATION=off against =use " + "compares the built-in table with a machine calibration (SEP 46)", + ) + + +def arm_environment(settings: list[str]) -> dict[str, str]: + """Return an arm's own variables from ``NAME=VALUE`` settings.""" + environment: dict[str, str] = {} + for setting in settings: + name, separator, value = setting.partition("=") + if not separator or not name: + raise SuiteError(f"an arm's environment takes NAME=VALUE, got {setting!r}") + environment[name] = value + return environment def arm_configuration(configuration: Record, backend: str) -> Record: @@ -231,6 +256,10 @@ def ab(args: argparse.Namespace) -> int: "baseline": prepared_python(args.baseline_python), "candidate": prepared_python(args.candidate_python), } + environments = { + "baseline": arm_environment(args.baseline_env), + "candidate": arm_environment(args.candidate_env), + } harness = output / "harness" atomic_record( output / "plan.json", @@ -239,6 +268,7 @@ def ab(args: argparse.Namespace) -> int: "purpose": "exploratory A/B", "pythons": pythons, "backends": backends, + "environments": environments, "rounds": args.rounds, "repeats": args.repeats, "length": args.length, @@ -250,6 +280,7 @@ def ab(args: argparse.Namespace) -> int: }, immutable=True, ) + calibrate_builds(pythons, args.calibrate, output, environments) samples: list[Record] = [] provenance: dict[str, list[Record]] = {arm: [] for arm in ARMS} allocation = eligible_cpus() @@ -274,7 +305,9 @@ def ab(args: argparse.Namespace) -> int: if round_index == 0 else None, }, - thread_environment(width, dict(os.environ), args.threading), + thread_environment(width, dict(os.environ), args.threading) + | worker_calibration(dict(os.environ) | environments[arm]) + | environments[arm], output / "raw" / f"r{round_index}-{arm}-{row['id']}", args.timeout, harness, @@ -315,6 +348,7 @@ def ab(args: argparse.Namespace) -> int: same = ( pythons["baseline"] == pythons["candidate"] and backends["baseline"] == backends["candidate"] + and environments["baseline"] == environments["candidate"] ) print( ("A/A control: the same interpreter for both arms. " if same else "") diff --git a/benchmarks/builders.py b/benchmarks/builders.py index 8c8575856..5030bd792 100644 --- a/benchmarks/builders.py +++ b/benchmarks/builders.py @@ -13,6 +13,7 @@ from __future__ import annotations import dataclasses +import os from typing import TYPE_CHECKING, Any from benchmarks.records import Record, SuiteError @@ -34,6 +35,37 @@ from solprop.stepper import Multistep, RungeKutta +NODE_BUDGET = "SOLPROP_BENCH_NODE_BUDGET" +"""A node budget in bytes for every case's sources, to measure the node chunk (#361). + +Set in one arm of ``benchmarks ab`` (``--candidate-env``), it compares two chunks with +one build. Unset, each case keeps its translation's default. +""" + + +def _node_budget() -> int | None: + """Return the node budget :data:`NODE_BUDGET` asks for, if any. + + Returns + ------- + int or None + Bytes, or ``None`` when the variable is unset. + + Raises + ------ + SuiteError + When the variable is not a positive integer. + """ + value = os.environ.get(NODE_BUDGET, "").strip() + if not value: + return None + if not value.isdigit() or int(value) <= 0: + raise SuiteError( + f"{NODE_BUDGET} must be a positive number of bytes, got {value!r}" + ) + return int(value) + + def integrator(stepper: str) -> Multistep | RungeKutta | None: """Return the integrator a stepper override names; ``None`` keeps the default.""" from solprop.stepper import Multistep, RungeKutta # noqa: PLC0415 @@ -539,6 +571,7 @@ def build(configuration: Record) -> Simulation: backend=configuration["backend"], fields=fields, field_planes=observed["field_planes"], + node_budget=_node_budget(), ) if case.step is not None: settings = dataclasses.replace( @@ -583,5 +616,6 @@ def build(configuration: Record) -> Simulation: raise SuiteError(f"unknown builder {case.builder}") if chosen is not None: observed["integrator"] = chosen + observed["node_budget"] = _node_budget() # None: the translation's default simulation.settings = dataclasses.replace(simulation.settings, **observed) return simulation diff --git a/benchmarks/history.py b/benchmarks/history.py index 2e3f60c64..474a9d7b7 100644 --- a/benchmarks/history.py +++ b/benchmarks/history.py @@ -40,6 +40,7 @@ "numa_domains", "threads", "threading", + "calibration", "case", "revision", "case_hash", diff --git a/benchmarks/runner.py b/benchmarks/runner.py index c9e55809f..34afd4912 100644 --- a/benchmarks/runner.py +++ b/benchmarks/runner.py @@ -161,6 +161,71 @@ def show_plan(rows: list[Record], skipped: list[str], runs: int) -> None: ) +CALIBRATION_LEVELS = ("thorough", "standard", "quick", "none") +"""``--calibrate``'s levels: how each build is calibrated before a series (SEP 46).""" + + +def worker_calibration(environ: dict[str, str]) -> dict[str, str]: + """Return a worker's ``SOLPROP_CALIBRATION``: ``use``, or ``off`` if asked. + + A worker applies the profile the series was calibrated with and never measures + inside a timed window (SEP 46 section 6.13); ``off`` holds the built-in table for + an experiment that must keep the settings fixed while the code changes. + """ + chosen = environ.get("SOLPROP_CALIBRATION", "").strip().lower() + return {"SOLPROP_CALIBRATION": "off" if chosen == "off" else "use"} + + +def calibrate_builds( + pythons: dict[str, str], + level: str, + output: Path, + environments: dict[str, dict[str, str]] | None = None, +) -> Record: + """Calibrate each distinct build at `level` before a series (SEP 46 section 6.13). + + Each interpreter runs ``python -m solprop calibrate --LEVEL`` once, with its arm's + own variables, so that an A/B's two builds each get their own profile, as two + releases would for a user; its report is kept under ``calibration/``. ``none`` + calibrates nothing and the series runs on whatever profiles exist; an arm under + ``SOLPROP_CALIBRATION=off`` runs the built-in table and calibrates nothing either. + """ + if level == "none": + return {} + import os # noqa: PLC0415 + + done: dict[str, Record] = {} + found: Record = {} + for arm, python in pythons.items(): + if python in done: + found[arm] = done[python] + continue + environment = dict(os.environ) | (environments or {}).get(arm, {}) + if worker_calibration(environment)["SOLPROP_CALIBRATION"] == "off": + found[arm] = {"level": "off"} + continue + completed = subprocess.run( + [python, "-m", "solprop", "calibrate", f"--{level}"], + capture_output=True, + text=True, + check=False, + env=environment, + ) + report = output / "calibration" / f"{arm}.txt" + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(completed.stdout + completed.stderr, encoding="utf-8") + done[python] = { + "level": level, + "status": completed.returncode, + "summary": completed.stdout.splitlines()[:1], + } + found[arm] = done[python] + print( + f"calibrated {arm} ({level}): {' '.join(completed.stdout.splitlines()[:1])}" + ) + return found + + def launch_worker( python: str, request: Record, diff --git a/benchmarks/track.py b/benchmarks/track.py index 454721357..2a0a6fbec 100644 --- a/benchmarks/track.py +++ b/benchmarks/track.py @@ -19,11 +19,14 @@ from benchmarks.records import LOCAL_ROOT, Record, SuiteError, atomic_record, now from benchmarks.registry import CASES, SETS, STEPPERS, select from benchmarks.runner import ( + CALIBRATION_LEVELS, ROOT, + calibrate_builds, launch_worker, plan_rows, receipt, show_plan, + worker_calibration, ) from benchmarks.system import ( THREADING, @@ -107,11 +110,23 @@ def selection_options(parser: argparse.ArgumentParser) -> None: ) +def calibration_option(parser: argparse.ArgumentParser) -> None: + """Declare ``--calibrate``: how each build is calibrated before the series.""" + parser.add_argument( + "--calibrate", + choices=CALIBRATION_LEVELS, + default="thorough", + help="calibrate each build at this level first, as a user would (SEP 46); " + "none keeps the profiles there are (default: thorough)", + ) + + def track_options(parser: argparse.ArgumentParser) -> None: """Declare the tracking run's output and history options.""" selection_options(parser) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--out", type=Path) + calibration_option(parser) parser.add_argument("--instance-type", help="cloud instance type, if any") parser.add_argument( "--append-history", @@ -157,6 +172,7 @@ def summarise( "threading": threading, "status": result["status"], "busy": busy, + "calibration": result.get("calibration"), } if result["status"] != "ok": return {**base, "error": result.get("error")} @@ -250,6 +266,7 @@ def history_rows( "numa_domains": machine["numa_domains"], "threads": row["threads"], "threading": row["threading"], + "calibration": row.get("calibration"), "case": row["case"], "revision": row["revision"], "case_hash": CASES[row["case"]].digest[:12], @@ -378,6 +395,7 @@ def track(args: argparse.Namespace) -> int: }, immutable=True, ) + calibrate_builds({"build": sys.executable}, args.calibrate, output) started = time.perf_counter() results: list[Record] = [] provenance: Record = {} @@ -388,7 +406,8 @@ def track(args: argparse.Namespace) -> int: events, elapsed = launch_worker( sys.executable, {"configuration": row["configuration"], "repeats": args.repeats}, - thread_environment(width, dict(os.environ), args.threading), + thread_environment(width, dict(os.environ), args.threading) + | worker_calibration(dict(os.environ)), output / "raw" / row["id"], args.timeout, ) diff --git a/benchmarks/worker.py b/benchmarks/worker.py index d4eabf818..d23d9738a 100644 --- a/benchmarks/worker.py +++ b/benchmarks/worker.py @@ -310,6 +310,7 @@ def measure(configuration: Record, repeats: int, fields: Path | None) -> Record: fields.parent.mkdir(parents=True, exist_ok=True) np.save(fields, final, allow_pickle=False) return { + "calibration": calibration_label(engine.metadata()), "construction_seconds": construction, "backend_seconds": backend_seconds, "warmup_seconds": warmup, @@ -341,6 +342,22 @@ def measure(configuration: Record, repeats: int, fields: Path | None) -> Record: engine.close() +def calibration_label(metadata: Record) -> str | None: + """Name the machine profile a run applied: ``built-in``, or its level and + generation, with ``+refinement`` where a stage-2 refinement applied (SEP 46).""" + found = metadata.get("calibration") + if not isinstance(found, dict): + return None + profile = found.get("profile") + label = ( + f"{profile['level']}:{profile['generation']}" + if isinstance(profile, dict) + else "built-in" + ) + source = str(found.get("workspace_source", "")) + return label + ("+refinement" if source.startswith("refinement") else "") + + def main() -> int: """Execute the JSON request passed by the controller.""" diagnostics = Diagnostics() diff --git a/changelog.d/sep-0046-machine-calibration.added.md b/changelog.d/sep-0046-machine-calibration.added.md new file mode 100644 index 000000000..5dc99fabc --- /dev/null +++ b/changelog.d/sep-0046-machine-calibration.added.md @@ -0,0 +1 @@ +`solprop calibrate` fits the native backend to the machine it runs on ([SEP 46](docs/dev/seps/archive/sep-0046-machine-calibration.md); [Calibrating solprop to your machine](docs/guide/performance/calibration.md)): in about 20 s (`--quick`), a minute and a half, or five minutes (`--thorough`), child processes pinned to the job's CPUs measure how idle threads should wait, how wide the team should be, where each kind of operation should start to thread and which FFTW plan effort runs fastest inside the step, and a setting replaces the built-in value only if it wins every round by more than the measured noise, slows none of the problems (production-sized ones among them) and validates with the other winners. The profile is kept per user in the platform's cache directory (`SOLPROP_CACHE_DIR` moves it; `SOLPROP_CALIBRATION_PATH` adds shared read-only directories, such as a cluster's per node type), keyed by machine, build and allocation, and every later run applies it and records the settings it applied in its metadata (`calibration`). The first native run with no profile for its machine, build and allocation calibrates quickly by itself after a `CalibrationNotice`, and a scan calibrates its workers' allocation first (unless they run one thread each, which has nothing to choose); `SOLPROP_CALIBRATION=use` never measures and `off` ignores profiles. `Simulation.calibrate()` and `solprop calibrate --recipe` refine the settings for one simulation, reused by every run and scan point of the same execution. `--show`, `--export`, `--import` and `--clear` manage profiles. Benchmark series calibrate each build first and record the profile per row. Without a profile, every run uses exactly the former settings, now a built-in table keyed by operating system; `BuildInfo`'s threshold fields report that table for the process. diff --git a/changelog.d/sep-0046-scan-worker-wait.changed.md b/changelog.d/sep-0046-scan-worker-wait.changed.md new file mode 100644 index 000000000..099791373 --- /dev/null +++ b/changelog.d/sep-0046-scan-worker-wait.changed.md @@ -0,0 +1 @@ +A scan's workers ask for passive waiting instead of setting `OMP_WAIT_POLICY=passive` themselves, so that a user's own `OMP_WAIT_POLICY`, `GOMP_SPINCOUNT` or `KMP_BLOCKTIME` now reaches them, and so does a machine calibration's row for the workers' allocation ([SEP 46](docs/dev/seps/archive/sep-0046-machine-calibration.md)); without either they wait as before. Their recorded `openmp_wait_policy` reads `passive`. diff --git a/docs/conf.py b/docs/conf.py index 9360744fe..5f19edd0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,11 @@ if _watchdog := os.environ.get("SOLPROP_DOCS_WATCHDOG"): faulthandler.dump_traceback_later(float(_watchdog), exit=False) +# A machine calibration (SEP 46) neither runs nor applies while the galleries run: +# their committed output must not show a first-use notice, nor depend on the machine +# that refreshed it. +os.environ.setdefault("SOLPROP_CALIBRATION", "off") + # Gallery examples may select the native backend automatically. Load it and its OpenMP # runtime before autodoc or a tutorial imports the rest of the stack, as the tests do. if importlib.util.find_spec("solprop_native") is not None: diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 371b4a11d..a48ac2224 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -222,6 +222,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `backends/numpy_ref/` | Reference execution | B | | `stepper/`, `log.py`, `stats.py` | Tableaux, controllers, schedules, progress reporters and statistics specifications | D | | `driver/`, `driver/executors/`, `scan.py` | Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | +| `calibration/` | The native backend's machine calibration ([SEP 46](seps/archive/sep-0046-machine-calibration.md)): reading, showing, exporting, importing and clearing profiles in the per-user store, through solprop-native's standard-library modules loaded by path so that the native runtime never starts (`calibration/_native.py`), the measuring controller, its child processes, the synthetic problems they time, the operation-grain candidates and the selection rule (`calibration/controller.py`, `child.py`, `problems.py`, `grain.py`, `selection.py`), the refinement of one simulation (`calibration/refine.py`, stage 2, which `Simulation.calibrate` runs), and the `solprop calibrate` command (`calibration/command.py`) | D | | `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts | D | | `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane or a fluence map (`processing/planes.py`), and a field plane's file form (`processing/plane_file.py`); propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | | `service/` | Front-end support, above every other layer: the messages between a front end and solprop, the kernel process that answers a front end's checks, previews and readings of a results file as plain data, each run as a job that keeps its record in its folder, and the helper and connection that reach a machine's runs over SSH ([the front end's design](gui/design.md), sections 13.3 and 13.4). Not part of the user API and outside the stability promise; nothing else in solprop imports it | X | diff --git a/docs/dev/benchmarking.md b/docs/dev/benchmarking.md index 36a64d1a6..102433737 100644 --- a/docs/dev/benchmarking.md +++ b/docs/dev/benchmarking.md @@ -126,6 +126,17 @@ plan gives no time estimate. uv run python -m benchmarks track --append-history ``` +A series measures a user's default run, so it runs calibrated +([SEP 46](seps/archive/sep-0046-machine-calibration.md) section 6.13): before the first +configuration, `track` runs `solprop calibrate --thorough` (`--calibrate` chooses another +level, or `none` to keep the profiles there are), and its workers apply the profile +(`SOLPROP_CALIBRATION=use`) without measuring inside a timed window. Each history row +records the profile its run applied in the `calibration` column (`built-in`, or the level +and generation, with `+refinement`), so a series that moves can be attributed to the +code or to a recalibration. `SOLPROP_CALIBRATION=off` holds the built-in table, for +experiments that must keep the settings fixed while the code changes; the first series +recorded after SEP 46 starts a new segment of the history. + The run's bundle goes to `.benchmarks/track/-/` (git ignores it): the plan, each worker's request, environment and logs, and `results.json`, which is rewritten after every configuration so that an interrupted run keeps what it @@ -230,6 +241,26 @@ uv run python -m benchmarks ab --case rdw --case argon_blueshift --threads 1 \ --candidate-python /path/to/candidate/bin/python ``` +Each build is calibrated before the rounds, once per interpreter and with its arm's +variables, as two releases would be for a user (`--calibrate`, as for `track`). +`--baseline-env` and `--candidate-env NAME=VALUE` set an arm's own variables over the +threading preset, and an empty value unsets one. A worker otherwise runs at the row's +width, which keeps a profile's team width out, and with it any setting fitted at +another width. To compare the built-in table with this machine's calibration as a +default run sees them, give both arms no thread count of their own: + +```sh +uv run python -m benchmarks ab --set tracking --threads 16 --production 16 \ + --calibrate none --length 0.2 --rounds 3 --repeats 1 \ + --baseline-python "$(which python)" \ + --baseline-env SOLPROP_THREADS= --baseline-env SOLPROP_NATIVE_THREADS= \ + --baseline-env SOLPROP_CALIBRATION=off \ + --candidate-env SOLPROP_THREADS= --candidate-env SOLPROP_NATIVE_THREADS= \ + --candidate-env SOLPROP_CALIBRATION=use +``` + +The same command with `SOLPROP_CALIBRATION=off` in both arms is its A/A control. + Selection works as for `track`. `--length` runs that fraction of each guide, from the entrance, for quick iteration. Each round runs every configuration once with each build, one worker each, and alternates which build goes first; `--rounds` sets the diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 86b9de191..37be043aa 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2646,6 +2646,137 @@ bin underflows harmlessly, so binary32 now holds it to growth alone; with the ed on the device and the operator built once, whole runs take 1.05 s on `mlx` against 2.64 s on `native` (192²) and 7.72 against 9.43 s (384²), the fields 2e-7 apart. +### Machine calibration against the built-in table (SEP 46) + +[SEP 46](seps/archive/sep-0046-machine-calibration.md) section 7 items 1–4. On each machine a +thorough calibration, then the tracking set calibrated against the built-in table +(`benchmarks ab`, a fifth of each case, three rounds of one run, both arms as default +runs: no thread count of their own, `SOLPROP_CALIBRATION=off` against `use`), an A/A +control of the same command, and a second calibration. Sixteen cores of each machine +unless stated; fields equal to rounding in every row. + +The first rules failed. A candidate was scored by the geometric mean of five small +problems, and the FFT stage's fallback published stored wisdom untested: +- **Google Axion (C4A):** a team of 8 won the small problems by 16 % and ran the large + cases slower (`kerr_beam_384` 1.82, `kerr_beam_192` 1.73, `radial_filament_256` + 1.35, `grin_nonlinear` 1.28, `ctc` 1.07); geometric mean 1.094. +- **AMD EPYC 9B45 (C4D):** the stored FFT wisdom ran `h2_raman_averaged` 1.18 times + slower (1.17 with the wisdom alone, 0.99 with the efforts alone): a 4608-point plan + chosen alone in the capture ran slower in the step than one measured in place. A full + width from 98304 elements (`fft_full`) ran `grin_nonlinear` 1.08–1.10 slower, and on + an AMD EPYC 7763 1.087. + +Revised rules: no problem may be slower than its noise plus 5 % (the run below used +2 %), four production-sized problems join the standard and thorough levels, FFT +planning is compared with the plan policy and published only when an effort wins, and +the transforms' thresholds stay at the table (#587). With them: + +| machine | calibration chose | geometric mean | A/A | slower than A/A + 2 % | +|---|---|---:|---:|---| +| Google Axion (C4A), three runs | medium width 8, Raman and copy thresholds | 0.965, 0.964, 0.968 | 1.000 | none | +| AMD EPYC 7763, exclusive node, one NUMA domain | the table (no grain move won) | 1.003 | 0.991 | none | +| AMD EPYC 9B45 (C4D), Windows Server 2025, the CI wheel | the table, twice | — | — | — | +| AMD EPYC 9B45 (C4D), Linux (5 % bar) | the table; a copy threshold in the second | — | — | — | +| Intel Xeon Gold 6240, one socket (18 cores) | the table, twice | — | — | — | +| Intel Xeon Platinum 8581C (Emerald Rapids, C4), 12 cores (5 % bar) | medium width 8, Raman and copy thresholds, twice | 0.989 | 0.996 | none | +| the same, at the quick level (first use) | medium width 8 | 0.999 | 0.996 | none | +| Apple M5 Max, six top-tier threads (5 % bar) | a Raman threshold of 8193, twice | 1.012 | 1.000 | none that the profile changes | + +On the Axion the gains are on the few-mode and Raman cases: `gnlse_raman_shock` +0.85–0.86, `he_rdw_averaged` 0.90–0.91, `argon_blueshift_envelope` 0.91–0.92, `ctc` +0.93–0.94, `argon_blueshift_averaged` 0.94–0.95; the large cases stay within +0.99–1.01. The team of 8 was refused on the production-sized radial problem +(1.17–1.18). The last run's two calibrations chose the same settings; earlier second +calibrations found the grain inconclusive and kept the table. + +Where the calibration chose the table, the A/B would compare the table with itself and +was not run (or, on the Xeon, measured only the shared machine's noise). + +The two machines the table was never tuned on, the Axion and the Emerald Rapids, chose +the same grain; on the Emerald Rapids `ctc` ran 0.87 and `gnlse_raman_shock` 0.94 (the +quick level, the medium width alone: `ctc` 0.90). Its second calibration also admitted +`threaded_rows measure`, under the FFT stage's earlier base (every plan at estimate), +since corrected; it was not A/B'd. + +Every machine refused passive waiting: 1.94 (Emerald Rapids), 1.10–1.11 (Axion), 1.16–1.23 (EPYC 7763), +1.25–1.27 (EPYC 9B45 Linux), 1.18 (Windows), 1.25 (Xeon), 1.74–1.78 (Apple M5 Max). On +the Apple M5 Max, under the first rules, two calibrations kept six top-tier threads and +admitted a Raman threshold of 8193; the A/B was 1.002 and 1.008 (`h2_raman_averaged` +1.04, the stored wisdom). Under the revised rules it chose the Raman threshold again, +twice, and no planning. The Raman cases did not move (`gnlse_raman_shock` 1.011, +`h2_raman_averaged` 1.001); `argon_blueshift_averaged` 1.155 and +`argon_blueshift_envelope` 1.088 ran the same computation in both arms (fields equal), +within the M5 Max's A/A range on those cases (0.89–1.12). ADR-073's table already fits +this chip. Inconclusive sessions published nothing: the EPYC 9B45 at +16 cores on two earlier VMs (A/A 35 % and 15 %; a third VM's session was conclusive at +2 %, and its table-against-table A/B spread 0.60–1.52 per case), the shared Xeon (23 %), the EPYC 7763's second session +(31 %). + +### Scan workers calibrated on an AMD EPYC 7763 node (SEP 46) + +[SEP 46](seps/archive/sep-0046-machine-calibration.md) section 7 item 14, a scan's workers. One +whole node of two AMD EPYC 7763 (128 cores, 8 NUMA domains), exclusive, GCC 16, commit +`ad1df42e` of #582. The workload is an RDW tuning scan: a four-mode helium capillary +(125 µm core radius, 5 cm, 7.5 fs at 800 nm, plasma) over pressure (0.6–1.2 bar) and +energy (150–275 µJ), run by `run_scan` on `Pool`. Each arm is one scan, in three rounds +of shuffled order: `table` (`SOLPROP_CALIBRATION=off`: workers wait passively), `spin` +(the table with `GOMP_SPINCOUNT=10000`, so workers spin) and `calibrated` (`auto`, after +one warm-up scan that calibrated the workers' allocation). Wall seconds: + +| config | table | spin | calibrated | warm-up (calibrating) | +|---|---|---|---|---:| +| A: 128 workers × 1 thread, 20 × 20 points | 464, 690, 415 | — | 505, 496, 459 | 577 | +| B: 4 workers × 4 threads on one NUMA domain, 4 × 4 points | 21.6, 25.9, 20.3 | 22.1, 20.4, 21.9 | 24.9, 23.0, 20.6 | 111.8 | +| C: 8 workers × 16 threads, 4 × 4 points | 25.9, 27.9, 26.2 | 25.8, 37.5, 25.9 | 29.0, 29.2, 30.6 | 114.6 | + +- **What the calibration chose.** The 4- and 16-core rows kept the table's waiting and + team (passive 1.13 and 1.18 times slower in the calibration's children, half the team + 1.46 and 1.08), found the grain inconclusive and planned nothing. A calibrated worker + therefore differs from the table's only in spinning, which is the `spin` arm. +- **Config C's calibrated arm** ran 1.05–1.17 times the table's in its rounds, but a + second job on the same node, nine shuffled scans, did not repeat it: `off` 27.4, 26.6, + 24.9, 24.9, 31.1; `use` (the same profile, no first-use check) 25.7, 25.9, 26.5, 27.6, + 31.0; `auto` 25.8, 25.4, 25.8. Scans of every arm land near 31 s at times. The + first-use check costs the scan's parent 0.4 s (0.2 s the second time). +- **Workers spinning or passive** (B and C): the same within the node's spread. The + calibration's lone-process verdict against passive waiting does not carry to + concurrent workers here, and does not cost them either. +- **Single-threaded workers** (A): the one-core class's standard calibration ran out of + its 90 s before three rounds of its problems in series and published nothing, to try + again a day later. One CPU has no team, waiting or grain to choose, so a one-CPU + allocation is now never measured. No setting differed between A's arms; the first + scan of each round ran slowest whichever arm it was (505, 690, 459 against 464, 496, + 415). +- **The one-off cost** is the warm-up's extra time: about 90 s a worker class, once per + machine, allocation class and release. + +### Device settings and the node chunk on an NVIDIA H200 (SEP 46 phase 6) + +SEP 46 phase 6 measures which device settings move a run beyond its noise (section +6.14). Builds of one commit, each changing one compiled setting, against the default +build (`benchmarks ab --device`, a fifth of each case, three rounds, CUDA 13.1); the +node chunk with one build and `SOLPROP_BENCH_NODE_BUDGET` in the candidate arm (#361). +A/A: geometric mean 0.996, each case within about ±3 % (`kerr_beam_192` to 1.05). + +| setting | geometric mean | beyond the noise | +|---|---:|---| +| graph replay off | 1.012 | `ctc` 1.08, `h2_raman_averaged` 1.07 slower | +| team 128, reduction 256 (from 256, 512) | 0.994 | `he_rdw_averaged` 0.94 (one round 0.996), `argon_blueshift` 0.965 | +| team 512, reduction 1024 | 1.009 | `ctc` 1.09 slower | + +The node chunk, against the 1 GiB default (Runge-Kutta, Multistep): + +| case | one chunk (64 GiB) | 8 GiB | +|---|---|---| +| `radial_filament_512` | 0.748, 0.924 | 0.749, 0.880 | +| `cartesian_filament_256` | 0.862, 0.888 | 0.877, 0.908 | +| `cartesian_filament_128` | 0.904, 0.935 | 0.911, 0.944 | +| `kerr_beam_384`, `kerr_beam_768` (fixed-step RK4IP) | 0.974, 0.996 | | + +Graph replay pays and stays on; neither width pays. The chunk pays 6–25 % on the +spatial routes, and 8 GiB takes nearly all of it. The device tests passed on the same +build (211 targeted, 31 native, 253 `-m gpu`, 92 radial). + ### A run's fixed host cost and a fresh process's BLAS [#347](https://github.com/LupoLab/solprop/issues/347) asked what a complete run costs diff --git a/docs/dev/cpu-performance.md b/docs/dev/cpu-performance.md index 3181b66dd..accd90f96 100644 --- a/docs/dev/cpu-performance.md +++ b/docs/dev/cpu-performance.md @@ -106,13 +106,17 @@ measured while it existed. - **Per-operation thread widths from the operation's dimensions**, chosen at construction ([measurements](benchmarks.md#kokkos-cpu-operation-size-threading)). An operation below its threshold runs serially; a medium one runs on four threads; a large one runs at full - width. The thresholds and their reasons are in `include/solprop_native/core.hpp`. The + width. The thresholds are a per-workspace record (`CpuExecution`), filled from the + built-in table in `native/src/solprop_native/_execution.py`, which holds their reasons, + or from a machine calibration ([SEP 46](seps/archive/sep-0046-machine-calibration.md)). The global thread setting is a cap, and explicitly set pools force their (clamped) widths. The small cases became 2.0 times faster than one thread (geometric mean) on an 18-core arm64 laptop and on a two-socket, 36-core AVX-512 server. - - The thresholds switch on `__APPLE__` alone: macOS has its own stage and FFT starts, - and every other build takes the x86-64 Linux values (Cascade Lake, Zen 3). Windows - and linux-aarch64 therefore run unmeasured thresholds. + - The built-in table is keyed by operating system: macOS has its own stage and FFT + starts and no stable kernel team, and every other system takes the x86-64 Linux + values (Cascade Lake, Zen 3); Windows and linux-aarch64 run them unmeasured unless + calibrated. A calibration's findings that should change the table are recorded + here, with their machines. - Two interactions were found and corrected during qualification: - a narrow response stage followed by a full-width FFT on the Cartesian filament; - a serialised coupled-flow matrix on the scalar wall. diff --git a/docs/dev/decisions.md b/docs/dev/decisions.md index f2f82f2da..890b508a1 100644 --- a/docs/dev/decisions.md +++ b/docs/dev/decisions.md @@ -2085,3 +2085,40 @@ Withdrawn (development-process record, not a design decision). thresholds; Linux, Windows and wider teams are unchanged. A grain calibrated per machine at start-up (from the measured region cost and a small-pass probe) is the general form and a lead; this decision measures one platform and keeps to it. + +## ADR-074 — A per-user calibration store, calibrated on first use, below explicit settings + +- **Status:** proposed with SEP 46's implementation (PR #582), for the maintainer. +- **Context:** the native backend's CPU execution settings (operation thresholds, medium and + compact widths, the stable team, the waiting policy and team width, FFT plan efforts) were + constants chosen on two or three machines; PR #576 found them not to transfer (ADR-073). + SEP 46 replaces them by a measured profile, which has to live somewhere, start somehow and + rank against the overrides users already rely on. +- **Decision:** + - *Store.* One directory per user, the platform's cache directory (`SOLPROP_CACHE_DIR` + moves it), with read-only shared directories after it (`SOLPROP_CALIBRATION_PATH`). + Profiles are keyed by a fingerprint of the machine, the operating system, the native + build (its build record and a digest of the extension) and the calibration's version, + and within it by allocation class; generations are published whole and named by an + atomic swap; a claim file per allocation class makes one process calibrate it. + - *Process and workspace settings.* The pool width and the waiting policy are fixed when + the runtime starts and read from the profile at import; operation grain and FFT + planning are copied into each workspace when it is built (`CpuExecution`, + `FftPlanning`), and apply only where they were validated under the process settings in + force. The built-in table, keyed by operating system, is a profile's fallback. + - *First use.* Under `SOLPROP_CALIBRATION=auto` (the default) the first native workspace + in an allocation with no row runs the quick calibration, after a `CalibrationNotice`; + importing never calibrates. A scan calibrates its workers' allocation at the standard + level first. `use` never measures; `off` ignores profiles. + - *Precedence.* The user's explicit settings first (`SOLPROP_THREADS`, the pool variables, + the wait variables, `SOLPROP_FFTW_PLAN`, `SOLPROP_FFTW_WISDOM`, + `SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES`); then a stage-2 refinement of the very + execution; then the allocation's row (an exact one, or the nearest smaller of the same + layout for workspace settings); then the built-in table. solprop's own requests (a scan + worker's passive wait, `SOLPROP_NATIVE_WAIT_REQUEST`) rank below a row. +- **Consequences:** solprop writes to the user's cache directory for the first time; a + read-only or missing directory leaves runs on the table, with one warning. A first native + run on a new machine, allocation or release takes about 20 s longer, once, and says why. + Every run records the profile and the settings it applied (`calibration`). The test + suites and the documentation build run with `SOLPROP_CALIBRATION=off`; benchmark series + calibrate each build thorough first and record the profile per row. diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 2c3331b14..cb57cb814 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -768,6 +768,19 @@ class Layer(NamedTuple): ), ), Module("scan.py"), + Module( + "calibration/", + ( + "command", + "controller", + "first_use", + "child", + "grain", + "problems", + "refine", + "selection", + ), + ), Module("driver/executors/", ("local", "pool", "queue", "slurm", "batch")), Module( "driver/", diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index ce8c0b074..fc09e3912 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -214,26 +214,31 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: callback · hdf5 · tdse_archive snapshots · scan · scan_artifacts resumable - + scan.py - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py + +calibration/ +command · controller · first_use +child · grain · problems · refine +selection + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py X diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 9a21c0726..0e5065bf3 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -34,12 +34,6 @@ being implemented. identifies a new material contract. Ne/Xe, mixtures, mode-averaged and multimode routes, bound response and other geometries remain follow-up work; the first route is a single-mode capillary with node quadrature. -- [SEP 46 — Calibrating the native backend to its machine](seps/sep-0046-machine-calibration.md): - a bounded one-off measurement that fits the native CPU backend's waiting policy, team, - operation thresholds and FFTW planning to the machine: quick and automatic on first - use, thorough for the benchmarks, kept in the user's cache directory and read by every - later run. An optional refinement for one simulation covers SEP 4's use case, which it - supersedes; a last phase fits GPU builds' settings when a device is set up. ## Draft enhancement proposals diff --git a/docs/dev/seps/archive/index.md b/docs/dev/seps/archive/index.md index 16e598a68..c1ec67c6a 100644 --- a/docs/dev/seps/archive/index.md +++ b/docs/dev/seps/archive/index.md @@ -42,4 +42,5 @@ sep-0042-tolerant-resume sep-0043-retire-numba-cpu sep-0044-native-edge-read sep-0045-native-rk-march +sep-0046-machine-calibration ``` diff --git a/docs/dev/seps/archive/sep-0004-execution-autotuning.md b/docs/dev/seps/archive/sep-0004-execution-autotuning.md index 2071c6801..af2c08eb0 100644 --- a/docs/dev/seps/archive/sep-0004-execution-autotuning.md +++ b/docs/dev/seps/archive/sep-0004-execution-autotuning.md @@ -14,7 +14,7 @@ | **Resolution** | — | | **Requires** | — | | **Supersedes** | — | -| **Superseded-by** | [SEP 46](../sep-0046-machine-calibration.md) | +| **Superseded-by** | [SEP 46](sep-0046-machine-calibration.md) | | **Implementation** | — | | **Manual pages** | — | @@ -878,7 +878,7 @@ GPU experiments or prototype tuning study were performed for this proposal. proposal was written before that, and its search over Numba's regimes and pools, with the settings it names (`SOLPROP_NUMBA_*`, `SOLPROP_FFTW_THREADS`), no longer has anything to act on: it needs reworking on the native backend's pools before it can be accepted. -- 2026-11-08: Superseded by [SEP 46](../sep-0046-machine-calibration.md), accepted the same day: its +- 2026-11-08: Superseded by [SEP 46](sep-0046-machine-calibration.md), accepted the same day: its per-simulation search, plan, selection rules and pilot contract are replaced there; worker counts (#314), GEMM providers, CPU node chunks and propagator policy (#303) stay on their issues. Never implemented. diff --git a/docs/dev/seps/sep-0046-machine-calibration.md b/docs/dev/seps/archive/sep-0046-machine-calibration.md similarity index 72% rename from docs/dev/seps/sep-0046-machine-calibration.md rename to docs/dev/seps/archive/sep-0046-machine-calibration.md index 4e52fa30b..c46f4b06b 100644 --- a/docs/dev/seps/sep-0046-machine-calibration.md +++ b/docs/dev/seps/archive/sep-0046-machine-calibration.md @@ -6,17 +6,17 @@ | **Title** | Calibrating the native backend to its machine | | **Author** | John Travers | | **Drafting agent** | Opus 5.5 (2026-11-08) | -| **Status** | Accepted | +| **Status** | Final | | **Review status** | Reviewed | | **Reviewers** | GPT-6 (AI); John Travers (human) | | **Type** | Change | | **Created** | 2026-11-08 | | **Resolution** | Accepted 2026-11-08 by John Travers, after the AI review, the drafting agent's response and the maintainer's decisions on §12 items 1–7 ([LupoLab/solprop#580](https://github.com/LupoLab/solprop/pull/580)). | | **Requires** | — | -| **Supersedes** | [SEP 4](archive/sep-0004-execution-autotuning.md) | +| **Supersedes** | [SEP 4](sep-0004-execution-autotuning.md) | | **Superseded-by** | — | -| **Implementation** | — | -| **Manual pages** | — | +| **Implementation** | Opus 5.5 (AI) for John Travers, branch `feature/sep-0046-machine-calibration` ([#582](https://github.com/LupoLab/solprop/pull/582)): started 2026-11-08; phases 0–6 complete and verified (2026-11-09; on a Google Axion, AMD EPYC 7763 and 9B45 under Linux and Windows, Intel Xeon Gold 6240 and Platinum 8581C, an Apple M5 Max and an NVIDIA H200) | +| **Manual pages** | [Calibrating solprop to your machine](../../../guide/performance/calibration.md) | ## Part I — Proposal @@ -145,7 +145,7 @@ one more machine. This SEP stops treating them as constants. source's node chunk from `node_budget`, whatever the device (#361). The device bootstrap scripts build from source on every rental. - Every choice can be overridden by an environment variable - ([tuning guide](../../guide/performance/tuning.md)). A run records its pools, + ([tuning guide](../../../guide/performance/tuning.md)). A run records its pools, budget and waiting in its metadata (`execution_sizing`, `thread_budget`, `openmp_wait_policy`). @@ -844,6 +844,405 @@ index entry and tracking issue change then. ## Part II — Implementation record +### Phase 0 report — the store, the split and the built-in table + +- **Scope:** phase 0, 2026-11-08, Opus 5.5 (AI) for John Travers, on + `feature/sep-0046-machine-calibration` (LupoLab/solprop#582), commits `47271792` and + `80f2fe31`, on `main` after #576 and #581. +- **Delivered:** + - `native/include/solprop_native/core.hpp`, `native/src/core.cpp`, + `native/src/general.cpp`, `native/src/general_modal.cpp`, + `native/src/bindings.cpp`: the workspace settings leave the source. A + `CpuExecution` record (each operation's threshold and full size, the response + bounds, the medium and compact widths, the compact bound, the stable-team rule) is + a field of the native problem, copied into every `CpuExecutionPool` of a workspace, + its general modal source and its batch, which refuses members whose records differ. + `operation_threads` reads it instead of constants; `ThreadPolicy` keeps only + process settings. A problem without a valid record is refused. `State.cpu_execution` + reads it back. + - `native/src/solprop_native/_execution.py`: the built-in table, keyed by operating + system, with the measurements behind each value moved from `core.hpp`'s comments; + the `__APPLE__` switches and ADR-073's divisor are rules of the table, applied for + the process at import (`BUILTIN_EXECUTION`). + - `native/src/solprop_native/_store.py`, `_topology.py`, `_allocation.py`, + `_fingerprint.py`, `_profile.py`: the store (§6.8), topology and allocation classes + (§6.4), the fingerprint (§5), and the choice of row with its precedence rules; + standard library only. + - `native/CMakeLists.txt`: `_build.json` beside the extension. + - `native/src/solprop_native/__init__.py`, `_wait.py`, `backend.py`: at import, an + exact row's pool width and wait apply before the runtime starts unless the user set + them; each workspace takes the record `PROFILE.workspace` resolves (or one given to + `NativeBackend(cpu_execution=...)`, for stage 2); `metadata()["calibration"]`. + - `src/solprop/calibration/` and `solprop calibrate --show/--export/--import/--clear` + (`src/solprop/cli.py`), loading the pure modules by path; `CalibrationError`. + - Tests: `native/tests/test_execution.py`, `test_store.py`, `test_profile.py`, + `test_profile_import.py`, `tests/calibration/test_profiles.py`; the C++ tests take + `native/tests/native/execution.hpp`'s record. Both conftests default + `SOLPROP_CALIBRATION=off`, so that a developer's store never changes a test. + - `docs/dev/architecture.md` §4 and figure 1, `docs/guide/setup/recipes.md`, and + `changelog.d/sep-0046-machine-calibration.added.md`. +- **Deviations from the proposal:** + - *LLVM's block time inside native calls stays a process setting.* §5 lists it among + the workspace settings, but the outermost `CpuTeamScope` on a thread sets it, so two + workspaces nested or batched in one call could not hold different values, and the + waiting candidates of phase 1 (passive, a spin scoped to native calls, a + process-wide spin) already choose it. + - *The planning transaction moves to phase 3,* beside the per-class wisdom it applies. + Until phase 3 writes wisdom there is none to apply, and a run plans as today. + - *No schema change.* §9 expected one; the record goes into the backend's free-form + settings, which ADR-063 does not version. + - *The build is identified by a digest of the extension file,* with `_build.json` for + its configuration. The native source digest is generated at build time and is + readable only from the loaded extension; the file's digest also separates two builds + of one source. An editable install keeps its built files apart from its sources, so + every directory of the package is searched. + - *Claims are per allocation class* (`claim-`), and inconclusive attempts are + kept in `attempts.json`, so that `auto` can wait a day (phase 5). + - *Two edges move.* A user's `SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES` equal to the + default is no longer divided on a spinning Mac, as any other value already was; and + `BuildInfo`'s threshold fields report the process's table, divided where runs divide + it, where they reported the undivided constants. +- **Decisions:** the precedence of §5 (the user's settings, then a refinement, then the + row, then the table), the record's 24 fields as its native and stored form, and the + allocation class's span structure (kinds, shared L2 groups, L3 groups, NUMA nodes, + sockets, SMT, a quota) are the store's ADR, written with phase 5. +- **Verification:** + - The table equals the former constants on every system, frozen per platform + (`test_execution.py`), including the spinning Mac's divided values. + - On the Xeon (GCC 16.2, OpenMP, Release): the native C++ tests (`cpu-debug` preset) + pass; the native Python suite passes (614 tests before one fixture fix, then the + affected modules again); `tests/test_process.py`, `test_native_forced_threads.py`, + `test_driver.py`, `tests/calibration`, the recipe tests and the structure figures + pass. + - §7 item 7: a direct import applies a hand-written profile's width, wait and + workspace settings and records them, read back (`test_profile_import.py`); two live + states hold different records (`test_execution.py`). The notebook case belongs to + phase 5's first use. + - §7 item 8: exact and nearest-smaller rows, equal core counts on one and two sockets, + an override of the thread count or the wait sending workspace settings back to the + table (`test_profile.py`, `test_profile_import.py`). + - §7 item 10: concurrent claims admit one, stale claims by deadline and by a dead + owner, two hosts, corrupt and foreign generations, a failed publication keeping the + previous generation, read-only directories (`test_store.py`); Windows runs in CI. + - `solprop calibrate --show` and the native import agree on this machine's fingerprint + and allocation, and the command never loads the extension. +- **Sources checked:** none new; the table's values and measurements were moved from + `core.hpp` unchanged. +- **Limitations:** + - Windows reads its first processor group only (64 logical CPUs) and no core kinds; + macOS has no affinity, so its classes count cores by kind. + - The scan workers' settings are still Pool's explicit variables until phase 5 marks + them as solprop's. +- **Open questions:** + - Q1. solprop's TDSE consistency cache keeps the Linux-style cache directory on macOS, + while the store uses the platform's (`Library/Caches` in the home directory). Should + the two share the platform's directory? (#583) +- **Handoff:** phase 1 builds the controller, its children and §6.5's selection on + `_profile`, `_store` and `NativeBackend(cpu_execution=...)`; measuring starts there. + +### Phase 1 report — the controller; waiting, team and region cost + +- **Scope:** phase 1, 2026-11-08, Opus 5.5 (AI) for John Travers, on + `feature/sep-0046-machine-calibration` (#582), commits `4baf5f94`, `e6398638` and + `d855e8f9`. +- **Delivered:** + - `src/solprop/calibration/selection.py`: section 6.5's rule, pure. + - `src/solprop/calibration/child.py`: one candidate's process settings, pinned before the + runtime starts, serving JSON lines (prepare, time, sizing, capture, regions, exit). + - `src/solprop/calibration/problems.py`: the five calibration problems. + - `src/solprop/calibration/controller.py`: the levels, one monotonic deadline, the + built-in child timed twice a round for the A/A noise, host load, hypervisor steal and + busy SMT siblings, the winner's validation by window, publication that keeps other + classes' and higher levels' rows, nested allocations, and every child reaped. + - `solprop calibrate [--quick|--thorough] [--budget S]`; `solprop.calibration.calibrate`. + - `native/src/solprop_native/_wait.py`: `SOLPROP_NATIVE_WAIT_REQUEST`, solprop's own + request for a wait, below the user's variables and a row. + - `benchmarks/ab.py`: `--baseline-env` and `--candidate-env`. +- **Deviations from the proposal:** + - *Windows are trial steps* (`StepDriver.timed_step`), repeated from the initial state: + the same work for every candidate, without the step controller. + - *A row is published when the built-in rules win*, with their values, so that `auto` + does not calibrate the allocation again. + - *Children warm each window with two untimed steps*: without them the A/A noise on the + Xeon was 6–8 %, the first window after another child paying for its evicted caches. +- **Verification** (Intel Xeon Gold 6240, GCC 16.2, socket 1 unless stated): + - `tests/calibration/test_selection.py` (ties, drift, outliers, missing rounds, a win on + average but not in every round, a failed combination, the drop order) and + `test_controller.py` on scripted children (every outcome, deadlines, failed children, + held claims, nested classes, publication rules); one real quick calibration (medium). + - Standard on 18 cores: 23 s before phases 2–3; passive waiting 1.26–1.32 times slower, + half the socket 0.97–0.99, the built-in rules kept. Quick: 8–13 s. + - The whole machine (36 cores, two sockets): A/A noise 10–23 % in three runs, so + inconclusive, as the Xeon's 36-thread timings are known not to repeat; its socket + (18 cores with their siblings) got its row as a nested allocation. +- **Limitations:** nested allocations run only while a third of the budget is left, which + at the standard level is rarely after phases 2 and 3; the thorough level covers them. +- **Handoff:** phase 2 fits the grain in the chosen child. + +### Phase 2 report — operation grain + +- **Scope:** phase 2, 2026-11-08, commit `eccf9ec7`. +- **Delivered:** `src/solprop/calibration/grain.py` (candidates from the problems' + `execution_sizing`, the width rule, the best of each setting) and the controller's grain + stage, timed on the problems each candidate changes and validated together. +- **Deviations from the proposal:** + - *Candidates come from the work sizes the problems run, not from per-operation + timings through Kokkos Tools.* A kernel's label does not name its operation class, and + an operation's threshold matters only where it falls between two work sizes a problem + runs; so a class's candidates are its one-step moves past the nearest of them (thread + one more size, serialise one, widen one, narrow one), at most four, with the medium + width (2, 8) and the compact team (4, 16) beside them. §6.5 still decides each. + - *Quick fits the medium width and compact team only* (its "coarse grain"); standard + moves thresholds, at most ten candidates; thorough moves full sizes too. + - *The Adams trial's passes and a coupled flow keep the table*: no trial-step window + runs them. +- **Verification:** `tests/calibration/test_grain.py`, the grain cases of + `test_controller.py`. On the Xeon's socket, standard admitted a medium width of 8 and a + compact team of 4: windows' geometric mean 0.885 (`line` 0.71, `radial` 0.81, + capillary 1.02); every stage, FFT and response threshold move lost by 2–126 %, as + ADR-073 found for this machine. A later run on the shared machine admitted the medium + width alone; another was inconclusive (A/A 0.11–0.23) while another user's job ran on + the socket's hyperthreads, which the SMT check now reports. +- **Limitations:** whether these admissions hold on the tracking set is §7 item 1's + measurement, still to run. + +### Phase 3 report — FFTW planning + +- **Scope:** phase 3, 2026-11-08, commit `33e9934a`. +- **Delivered:** `FftPlanning` and `FftPlanningScope` (`core.hpp`, `fft_plan.hpp`), the + seeds' `begin_seed`/`end_seed`, `State.fft_planning`, the controller's FFT stage, the + row's `planning` and the generation's wisdom, `PLANNING` at import and + `NativeBackend(fft_planning=...)`. +- **Deviations from the proposal:** + - *Efforts are chosen per group* (serial rows, threaded rows, transverse planes), the + policy's own split, not per shape class; the wisdom is still per exact plan key, and + a run's shape the profile does not hold is seeded at its group's effort. + - *The planning transaction is per workspace*: it forgets before planning and after, + imports each plan's own wisdom at its seed and builds it with `FFTW_WISDOM_ONLY`. The + seeds and the Kokkos-FFT plans are not interleaved in the code (a workspace seeds + several transforms, then plans them), so an estimate plan can still meet wisdom that + another plan of the *same* workspace imported, as today's measured seeds already + allow; nothing from an earlier workspace, a file or another candidate reaches it. + - *Captures need no fresh process*: each capture forgets before its seed, so it holds + exactly that plan's wisdom. +- **Verification:** `native/tests/test_planning.py` (§7 item 9: stale wisdom never + reaches a profiled workspace, two workspaces in either order, unbuildable wisdom falls + back and is recorded, malformed planning refused); controller tests. On the Xeon's + socket the 16 transforms of the five problems planned at measure in 10 s; serial rows at + estimate lost (1.12); threaded rows at measure won once (0.96, validated at 0.972, + `line` 0.89) and were inside the noise in a later run. +- **Open questions:** Q2. §12 item 10's alone-against-in-step timings are not recorded: + the capture times planning, not the plan alone. + +### Phase 4 report — stage 2 + +- **Scope:** phase 4, 2026-11-08, commit `9677809b`. +- **Delivered:** `src/solprop/calibration/refine.py`, `Simulation.calibrate`, + `solprop calibrate --recipe`, `_profile.descriptor`, refinements in the store + (`problems//.json`) and the backend's lookup. +- **Deviations from the proposal:** + - *Windows are trial steps at the start and a third and two thirds along the run*, from + the launch field, with the operators there; accuracy compares the trial steps' fields + with the base's (within ten times rtol), so the "same acceptance structure" test is + moot. A low-storage march times its own step and has no fields to compare. + - *The descriptor digests the operations' keys from `execution_sizing`* (class and work + of every operation: dimensions, chunk, response buffers, transform sizes), the shape, + grid, integrator, process settings and fingerprint; the plan keys follow from these and + are not listed separately. + - *A refinement applies only while the machine record it refined is the process's*, so it + never outlives the profile beneath it. +- **Verification:** `tests/calibration/test_refine.py` on real native workspaces with + scripted timings (a stored refinement reused by a later backend, not by another grid, + not after the profile changed; inaccurate candidates and combinations kept back; noise, + deadline, no profile); a real refinement of the radial filament ran in 34 s and was + inconclusive on the shared Xeon (A/A 0.15). + +### Phase 5 report — first use, scans, benchmarks and documentation + +- **Scope:** phase 5, 2026-11-08, commits `59e15e4d`, `bc66ec18`, `c83cc0ca`, `02062f20`. +- **Delivered:** `src/solprop/calibration/first_use.py`, the hooks in + `backends/registry.py` and `driver/executors/pool.py`, `CalibrationNotice`, + `solprop_native.refresh_profile`, the scan workers' wait request and role, the Slurm + script's request; `benchmarks` `--calibrate`, worker `SOLPROP_CALIBRATION=use`, the + history's `calibration` column; `docs/guide/performance/calibration.md` and the other + pages of section 8; ADR-074; the release notes. +- **Deviations from the proposal:** + - *The notice is a warning and a log record, not a reporter call*: the run's reporter is + resolved after the backend is chosen. The GUI service already turns warnings into + events. + - *The remote install script does not calibrate*: a GUI-managed host is often a + cluster's login node, which is the wrong machine and shared; compute nodes calibrate + on first use. +- **Verification:** `tests/calibration/test_first_use.py`; the Slurm snapshots; the + benchmark tests. A first native run in a fresh script on the Xeon's socket printed the + notice once, calibrated and ran in 19 s; the second run did not calibrate. +- **Limitations:** §7 items 1–3, 13 and 16 need the A/B campaign on the machines of §7 + item 1 (the Xeon quiet, DMOG's EPYC 7763, Google C4D under Linux and Windows and C4A, + and the maintainer's M5 Max), which is still to run, as is phase 6. + +### Verification report — section 7 items 1–4, and the revisions it forced + +- **Scope:** section 7 items 1–4 and 16 on the machines of item 1, 2026-11-08, Opus 5.5 + (AI) for John Travers; the maintainer ran the Apple M5 Max. Commits `27fb0131` to + `0aaf84f0` of #582. The rows, commands and spend are in the benchmark ledger + ([Machine calibration against the built-in table](../../benchmarks.md#machine-calibration-against-the-built-in-table-sep-46)). +- **Method:** a thorough calibration on 16 cores (18 on the Xeon, 6 top-tier on the + M5 Max), then the tracking set calibrated against built-in (`benchmarks ab`, a fifth + of each case, three rounds), an A/A control and a second calibration. Both arms run + as default runs, with no thread count of their own (an empty `SOLPROP_THREADS` and + `SOLPROP_NATIVE_THREADS`). Without that, a worker's explicit width kept the profile's + team width out, and with it every setting fitted at another width: the first Axion A/B + measured nothing of its profile (every case 1.00, fields equal to the bit). +- **The first rules failed item 1.** + - *Team width from small problems.* On a Google Axion a team of 8 won the five + problems by 16 % and ran the tracking set's large cases up to 1.8 times slower + (geometric mean 1.094). A round's score was the geometric mean over the problems, + so a large win on some carried a loss on others, and every problem was far smaller + than a production run. + - *Untested wisdom.* On an AMD EPYC 9B45 the FFT stage's fallback, the policy's + efforts with the captured wisdom, ran `h2_raman_averaged` 1.18 times slower. Its + 4608-point plan, chosen alone in the capture, ran slower in the step than a plan + measured in place, and the fallback was every candidate's base, so it was never + compared with the table. + - *Element-count thresholds.* A full transform width from 98304 elements won 0.56 on + the line problem's many short rows and ran `grin_nonlinear` 1.08–1.10 slower on + both EPYCs. +- **Revisions (deviations from sections 6.5, 6.7 and 6.9, all in `75cae69b` to + `0aaf84f0`):** + - *Per-problem admission.* A candidate must also be slower on no problem than its + median ratio's bar, `1 + noise + TOLERANCE`; the combined validation uses the same + bar. `TOLERANCE` is 5 %, the maintainer's choice (section 7 item 1 asked for 2 %). + - *Production-sized problems.* Standard and thorough also time `gnlse_large` (16384 + samples), `capillary_large` (four modes on 5120), `radial_large` (384 nodes) and + `line_large` (4096 columns). Quick tries no narrower team. Standard takes about 90 s + (78 s on 8 Xeon cores). + - *FFT planning against the table.* Candidates and their validation are compared with + the plan policy as a run without a profile plans, and nothing is published unless an + effort wins. Each capture takes a budget out of the stage's share and keeps what it + planned when cut short; standard plans at 0.25 s a plan. The grain stage leaves + planning half the time left. + - *Transforms at the table.* The grain no longer moves `fft_threshold` or `fft_full` + (#587: key the transforms' width on rows and row length). +- **Results with the revised rules** (2 % bar): + - *Google Axion:* medium width 8 and two thresholds in three runs; geometric means + 0.965, 0.964, 0.968 against an A/A of 1.000, no case beyond its A/A range plus 2 %; + gains to 15 % on the few-mode and Raman cases. The team of 8 was refused on + `radial_large` (1.17–1.18). The last run's two calibrations agreed (item 3). + - *AMD EPYC 7763 (exclusive node, one NUMA domain):* the table, as no grain move + won; A/B 1.003 against an A/A of 0.991. + - *AMD EPYC 9B45, Windows Server 2025 (the CI wheel, Clang 20, LLVM OpenMP):* the + table in both calibrations; the A/B, table against table, was not run. + - *AMD EPYC 9B45, Linux:* inconclusive at 16 cores on two VMs (A/A 35 % and 15 %; + one Spot VM was preempted mid-run); on a third (5 % bar) conclusive, keeping the + table (passive 1.25, half the team 1.06); its second calibration kept the wait and + team and admitted a copy threshold. The VM type's timings spread widely: its + table-against-table A/B ran 0.60–1.52 per case. + - *Intel Xeon Gold 6240, one socket:* the table in both conclusive calibrations; the + machine is shared, and a later session was inconclusive (23 %). + - *Intel Xeon Platinum 8581C (Emerald Rapids), 12 cores* (5 % bar): the table's + waiting and team (passive 1.94 times slower), medium width 8 and the Axion's Raman + and copy thresholds, in both calibrations; A/B 0.989 against an A/A of 0.996, + `ctc` 0.87, `gnlse_raman_shock` 0.94, no case beyond its noise. The quick level + (first use) admitted the medium width alone: 0.999, `ctc` 0.90 (section 7 item 13). + - *Apple M5 Max* (first rules): six top-tier threads kept in both calibrations, a + Raman threshold of 8193 admitted; A/B 1.002 and 1.008, `h2_raman_averaged` 1.04, + the stored wisdom the revision removes. With the revised rules (5 % bar), the same + Raman threshold in both calibrations and no planning; A/B 1.012 against an A/A of + 1.000, no case the profile changes beyond its noise (the argon cases' 1.09–1.15 ran + identical computations, within their A/A range of 0.89–1.12). +- **Item 2.** Found as measured by hand: spin everywhere, passive refused by 1.10–1.78; + six top-tier threads on the M5 Max; the Xeon's thresholds near the table; Windows' + spin, now the table's since #576. Not found: the M5 Max's lower thresholds beyond + ADR-073's, which the table already applies; the Xeon's compact cap near 8. +- **Item 3.** Same waiting and team in every pair of conclusive calibrations; the same + grain in the last Axion pair and in every M5 Max pair, while three earlier second calibrations (Axion, + EPYC 7763, Windows) found the grain inconclusive and kept the table. +- **Item 4.** Every noisy session reported `inconclusive` and published nothing for its + allocation: the EPYC 9B45 (35 %, 15 %), the EPYC 7763's second (31 %), the Xeon (23 %). +- **Item 16.** Not met by the first rules (above); under the revised rules no machine + published planning except one EPYC 9B45 calibration (`rows patient`), not yet A/B'd. +- **Open:** the few-mode cases keep part of the gain a narrower team gave them on the + Axion (`h2_raman_averaged` 0.90 with a team of 8, 1.00 at full width with the grain; + `argon_blueshift_envelope` 0.80 against 0.91): their operations sit above the full + sizes, and a grain move must win alone. Searching medium width and full sizes + together, and a source's transforms as one batch (#584), are the leads. The EPYC 9B45's + VMs are too noisy to resolve gains of a few per cent. + +### Scan-worker report — section 7 item 14 on a cluster node + +- **Scope:** a scan's workers, 2026-11-09, Opus 5.5 (AI) for John Travers, on a whole + node of two AMD EPYC 7763 (128 cores), commit `ad1df42e`; the rows are in the + benchmark ledger + ([Scan workers calibrated on an AMD EPYC 7763 node](../../benchmarks.md#scan-workers-calibrated-on-an-amd-epyc-7763-node-sep-46)). +- **Method:** the maintainers' common scan, an RDW tuning scan of a four-mode helium + capillary over pressure and energy, on `Pool`: 128 single-threaded workers over + 20 × 20 points, 4 workers of 4 threads on one NUMA domain, and 8 workers of 16 threads. + Each against the table (workers passive) and the table with spinning workers, three + shuffled rounds, after one warm-up scan that calibrated the workers' allocation. +- **Found:** + - *Nothing to gain.* The 4- and 16-core rows kept the table (spin, the full team, the + grain inconclusive, no planning), so a calibrated worker differs only by spinning. + Spinning and passive workers ran the same within the node's spread; a slower + calibrated arm at 8 × 16 (1.05–1.17) did not repeat in nine further scans, and the + parent's first-use check costs 0.4 s. + - *A defect, fixed.* A one-core allocation's standard calibration ran out of its 90 s + before three rounds of its problems in series, published nothing and would try again + a day later. One CPU has no team, waiting or grain to choose: a one-CPU allocation + is now never measured (`SINGLE_CPU`), so single-threaded scans neither wait nor + see a notice (a deviation from sections 6.4 and 6.10: one CPU is never calibrated implicitly). + - *The one-off cost:* about 90 s a worker class, once per machine, allocation class + and release. +- **#314** (workers against threads a worker) stays open: this measured each split's + waiting, not the split itself. + +### Phase 6 report — GPU builds + +- **Scope:** phase 6, 2026-11-08, on an NVIDIA H200 (CUDA 13.1, US$8.46); an AMD + Instinct MI325X was not available in the session. +- **Measured** (benchmark ledger, "Device settings and the node chunk on an NVIDIA + H200"): graph replay pays and stays on; team and reduction widths of 128/256 and + 512/1024 do not pay beyond the noise; the node chunk pays 6–25 % on the spatial routes + (#361's bar is 10 %), and an 8 GiB budget takes nearly all of it. The device tests + passed on the branch's build. +- **Decision** (the maintainer's): phase 6 keeps no calibrated device setting. There is + no `solprop calibrate --device`, no device fingerprint or `devices/` store, and no + bootstrap step: nothing would be fitted. The chunk is a better default, not a + per-machine measurement, and it raises a device run's default memory use, so it lands + through #361 as its own PR, qualified on CUDA and HIP. Section 7 item 15 is answered + by that: no device-calibrated setting, so nothing to verify beyond the measurements. +- **Delivered:** `SOLPROP_BENCH_NODE_BUDGET` in the benchmark builders (one arm of an + A/B at another node budget), and the measurements on #361. + +### Final report — completion + +- **Scope:** completion under SEP 0 §6, 2026-11-09, Opus 5.5 (AI) for John Travers. +- **Phases:** 0–6 are implemented and reported above; the verification and scan-worker + reports cover section 7 items 1–4, 14 and 16, and phase 6 answers item 15. The CI + gate passes on the branch. +- **Backends:** the calibration fits the native backend's CPU execution and leaves its + results unchanged beyond rounding (section 7 item 6). `numpy_ref`, `mlx` and Julia + have no execution settings to fit and are out of scope (section 2). +- **Manual:** [Calibrating solprop to your machine](../../../guide/performance/calibration.md) + is new and ends with a link here. The installation, tuning, recipes and native build + pages, the README, ADR-074 and the release notes say what changed. +- **Open questions moved to issues:** + - §12 item 1, a quick profile upgrading itself to standard: #591. + - The few-mode cases' narrower-team gain (verification report): #592, beside #584. + - The grain the Axion and the Emerald Rapids agreed on, as the table's: #593. + - §12 item 11, hybrid-core Linux and Windows machines: #594. + - §12 item 10, why plans chosen alone lose in the step: #595. + - §12 item 8 stays on #229, the transforms' thresholds on #587, the node chunk on + #361 and the scan split on #314. + - §12 item 3, answered: the waiting and team agreed in every pair of conclusive + calibrations and the grain did in the last pairs (item 3). A benchmark row records + the profile it ran on (`calibration`: its level and generation), so a step in a + series can be traced to a changed profile; series are not split by it. +- **Issues answered:** #328 (thresholds measured on Windows, aarch64 and macOS, and fitted + per machine), #332 (FFTW effort per shape and processor: measured, and the policy kept + where nothing won) and #115 (SEP 4, superseded). #81 and #329 stay open: the + calibration gained `gnlse_raman_shock` 6–15 % on the untuned machines without + explaining its scaling, and did not requalify the scalar wall. + ## Part III — History - 2026-11-08: Drafted (Opus 5.5) from the measurements of PR #576. Proposes to @@ -1190,3 +1589,15 @@ are left to the maintainer as §12 items 4–7. - 2026-11-08: Clarified before the acceptance merged, after an automated review (Codex) on #580: a first run, still on its original process settings, applies only workspace settings validated under them (§5, §6.4). +- 2026-11-08: Implementation started (Opus 5.5) on `feature/sep-0046-machine-calibration`. +- 2026-11-08: Phase 0 complete: the store, the split into process and workspace settings, + and the built-in table. +- 2026-11-08: Phases 1–5 implemented; their verification on the machines of section 7 item + 1 and phase 6 remain. +- 2026-11-08: Verification on the machines of section 7 item 1. The first rules failed + item 1; revised (per-problem admission at the noise plus 5 %, production-sized + problems, planning against the table, transforms at the table). Phase 6 measured on + an NVIDIA H200; no device setting kept as calibrated, the chunk to #361. +- 2026-11-09: Scan workers measured on an AMD EPYC 7763 node: the table kept, workers' + waiting immaterial; one-CPU allocations are no longer calibrated. +- 2026-11-09: Final. Open questions moved to #591–#595; archived. diff --git a/docs/dev/seps/index.md b/docs/dev/seps/index.md index c02a2618a..7c554e98f 100644 --- a/docs/dev/seps/index.md +++ b/docs/dev/seps/index.md @@ -33,7 +33,6 @@ Proposals that are active, in draft, accepted, in progress or deferred. | [11](sep-0011-finite-density-gas-index.md) | Finite-density gas index and mixture optics | Draft | Change | | [17](sep-0017-gas-two-photon-ionisation.md) | Broadband two-photon continuum response beside strong-field ionisation | Accepted | Feature | | [32](sep-0032-raman-maxwell-bloch.md) | Raman Maxwell–Bloch dynamics | Draft | Feature | -| [46](sep-0046-machine-calibration.md) | Calibrating the native backend to its machine | Accepted | Change | SEP 13, the standalone numerical prerequisite for SEP 12, SEP 14, which carried it to the compiled backends, and SEP 16, which unified the multistep family and made it the @@ -82,6 +81,7 @@ Proposals that are final, rejected, withdrawn or superseded; the | [43](archive/sep-0043-retire-numba-cpu.md) | Retire `numba_cpu` | Final | Change | | [44](archive/sep-0044-native-edge-read.md) | Reading the transverse edge where the state lives | Final | Change | | [45](archive/sep-0045-native-rk-march.md) | A native Runge–Kutta march, and boundary checks inside the compiled marches | Final | Change | +| [46](archive/sep-0046-machine-calibration.md) | Calibrating the native backend to its machine | Final | Change | ```{toctree} :hidden: @@ -98,7 +98,6 @@ sep-0010-low-rank-raman sep-0011-finite-density-gas-index sep-0017-gas-two-photon-ionisation sep-0032-raman-maxwell-bloch -sep-0046-machine-calibration template archive/index ``` diff --git a/docs/guide/performance/building-native.md b/docs/guide/performance/building-native.md index f052a3a98..bf3444f02 100644 --- a/docs/guide/performance/building-native.md +++ b/docs/guide/performance/building-native.md @@ -181,6 +181,10 @@ local label is matched exactly, so it takes no further label; `build_info()` say ## Checking a build +A new build is a new fingerprint: run `solprop calibrate` after it, as after an +installation ([Calibrating solprop to your machine](calibration.md)). Until then the +first native run calibrates quickly by itself. + ```sh python -m solprop_native | python -m json.tool python -c "import solprop_native as sn; sn.check_build(); print(sn.build_info().build_profile)" diff --git a/docs/guide/performance/calibration.md b/docs/guide/performance/calibration.md new file mode 100644 index 000000000..65d95a06e --- /dev/null +++ b/docs/guide/performance/calibration.md @@ -0,0 +1,206 @@ +# Calibrating solprop to your machine + +The native backend's CPU execution settings are properties of the machine: how each +operation threads (serially, on a few threads or on the whole team), how wide the team +is and which cores it takes, how idle threads wait between parallel regions, and how +much effort FFTW spends choosing its plans. A setting that runs one machine's cases 13 % +faster on average can run another's 1.4 to 1.7 times slower. **A calibration measures +them on your machine** and keeps the result, so that every later run uses it. It never +changes a result beyond rounding: it chooses among exact algorithms and thread counts, +as the built-in defaults do. + +```sh +solprop calibrate # standard: about a minute and a half +solprop calibrate --quick # about 20 s +solprop calibrate --thorough # about five minutes +solprop calibrate --show # the profile this job would use, and its evidence +``` + +Run `solprop calibrate` once after installing solprop, on the machine (or the kind of +node) that runs your simulations. If you skip it, the first native run calibrates +quickly by itself, with a notice (below). + +## What it measures + +The calibration runs in child processes, each pinned to the CPUs of your job, and times +short windows of five problems that solprop ships: a GNLSE with Raman response, a +four-mode capillary with plasma, a mode-averaged capillary, a radial filament and a +one-dimensional free-space beam. The standard and thorough levels also time four of +them at production size (16384 samples, four modes on 5120, 384 radial nodes, 4096 +columns), because a setting that speeds up small problems can slow large ones down. It +never runs your own simulation; for that, see +[refining one simulation](#refining-one-simulation). + +1. **Waiting and the team.** Idle threads either sleep at once or spin briefly before + the next region; spinning saves the 16 to 72 µs of a wake-up, sleeping frees the + cores. The team may also be narrower than the job: the fastest kind of core on a + chip with several, one socket of two (standard and thorough only, where the + production-sized problems can show what a narrower team costs). Each candidate is a + child process with those settings. +2. **Operation grain.** Each kind of operation (stage passes, responses, products, + Raman histories, copies) threads above a size threshold; the transforms keep the + built-in thresholds, which count elements, since transforms of one size thread best + in opposite ways by their shape. The + calibration moves each threshold past the work sizes the problems actually run, + and tries other medium widths and compact teams for one-column problems. +3. **FFT planning** (standard and thorough). Every transform the problems create is + planned alone at FFTW's `measure` effort, and serial ones at `patient` too at the + thorough level, within a time limit; each group of plans (serial rows, threaded rows, + transverse planes) keeps the effort that runs fastest *inside the step*, against + plans made as a run without a profile makes them. A more exhaustive plan is not + always a faster one, and a plan chosen alone not always the best in the step. Only + a winning effort's wisdom is kept, so later runs build those plans at once instead + of measuring again; without one, runs plan as they would without a profile. + +A candidate replaces the built-in value only if it **wins in every round** of +interleaved timings by more than the noise measured by timing the built-in setting +against itself, **and is slower on no problem** by more than that noise plus 5 %; the +winners are then validated together on fresh rounds, under the same bar. Ties and +anything uncertain keep the built-in value. On a busy machine (other work on the job's +cores or their hyperthreads, time stolen by a hypervisor, or timings noisier than 10 %) +the calibration says so and publishes nothing. + +| level | time | waiting and team | grain | FFT planning | rounds | +|---|---|---|---|---|---| +| `--quick` | about 20 s | the wait only | medium width and compact team | plan policy | 3 | +| standard | about 90 s | yes, two narrower teams | thresholds of the main operations | `measure`, 0.25 s a plan | 5 | +| `--thorough` | about 5 min | yes | thresholds and full sizes of every operation | `measure` and `patient`, 20 s a plan | 7 | + +The standard and thorough levels also calibrate the smaller allocations they can pin +children to inside the job, such as one socket, one NUMA node or one L3 cache group, +while their time allows, so that one calibration of a whole node covers most smaller +jobs on it. + +## Where the results live + +One directory per user, the platform's cache directory, which `SOLPROP_CACHE_DIR` moves: + +| platform | directory | +|---|---| +| Linux | `$XDG_CACHE_HOME/solprop`, or `~/.cache/solprop` | +| macOS | `~/Library/Caches/solprop` | +| Windows | `%LOCALAPPDATA%\solprop\Cache` | + +A profile is kept per machine and build: the processor and its cache and memory +layout, the operating system, the native build and the calibration's own version. A +new release, a rebuilt extension or another machine finds no profile and runs on the +built-in table, so nothing stale is ever applied. Within a profile there is one row per +**allocation**: the cores a job holds, of which kinds, and how many caches, NUMA nodes +and sockets they span. Eight cores sharing a cache behave differently from eight across +two sockets. + +A job takes its own allocation's row. Without one, it takes the workspace settings of +the row with the same layout and the nearest smaller core count, and otherwise the +built-in table. + +## The first run + +With no profile for the machine, the build and the job's allocation, the first native +run prints a notice like + +```text +CalibrationNotice: solprop is calibrating the native backend to this machine (quick, +about 20 s on 18 cores; once per machine, allocation and release). The profile is kept +in /home/me/.cache/solprop. Run `solprop calibrate` for a fuller calibration, or set +SOLPROP_CALIBRATION=use to never measure, or =off to ignore profiles. +``` + +and calibrates at the quick level before it starts. Importing solprop or the native +backend never calibrates. A script whose native backend has not loaded yet takes the +whole new profile; a notebook that has already run a native simulation applies the new +operation grain at once and the new waiting and team from its next session. + +`SOLPROP_CALIBRATION` chooses: + +| value | effect | +|---|---| +| `auto` (default) | use a profile; calibrate quickly on first use when there is none | +| `use` | use a profile; never measure implicitly | +| `off` | the built-in table and no stored FFT wisdom, whatever the store holds; for bisecting and for holding settings fixed while code changes | + +The notice is a Python warning of its own category, shown once; silence it with the +standard warnings filters (`warnings.filterwarnings("ignore", +category=solprop.exceptions.CalibrationNotice)`) or with `SOLPROP_CALIBRATION=use`. + +Concurrent first runs on one machine, such as the tasks of an array job, wait for the +one calibrating, then use its profile. A calibration that found the machine too busy +publishes nothing, and the next first run tries again after a day. + +## Scans and clusters + +A scan calibrates its workers' allocation at the standard level before it starts them, +in a child pinned like one worker, unless a standard or thorough row already matches; +a scan usually runs for a long time, often after waiting in a queue, so a minute up +front is cheap. Workers never calibrate. Remote scans calibrate on their own hosts. +A scan whose workers run one thread each never calibrates: one CPU has no team, +waiting or grain to choose. + +On a cluster, calibrate once per node type and share the profile: + +```sh +# on one node of each type, inside a job holding the whole node: +solprop calibrate --thorough +solprop calibrate --export /shared/solprop-profiles/node-type-a.json +# every user then points at the shared directory: +export SOLPROP_CALIBRATION_PATH=/shared/solprop-profiles +``` + +`SOLPROP_CALIBRATION_PATH` lists read-only directories searched after your own store, +separated like `PATH`; a file exported for one node type applies only on that type. +`solprop calibrate --import FILE` puts an exported profile into your own store. + +## Reading `--show` + +```text +allocation: 18 cores +profile: standard, generation g20261108T152341Z-1272949-c0d036, 2026-11-08T15:23:41Z + measured on this allocation +process: threads 18, wait spin +workspace: medium_threads 8 (built-in 4) +fitted under: threads 18, wait spin +decided: the built-in rules kept: no candidate won every round and its validation; + grain: admitted: medium 8; FFT planning: the plan policy's efforts +noise: 0.02 (A/A) +candidate: passive/18 1.272 (lost or tied in a round: worst ratio 1.280 against 0.980) +``` + +`process` is what the job's native runtime starts with, `workspace` each operation +setting beside the built-in value it replaces, and `fitted under` the process settings +those were validated under: a job that sets its own wait or thread count keeps the +built-in table for them. Each candidate's score is its median time relative to the +built-in setting. Every run records the settings it actually applied in its metadata +(`calibration` in the backend's settings), so a result says what produced it. + +`solprop calibrate --clear` forgets this machine and build's profiles. + +## Refining one simulation + +For a costly campaign, a second stage fits the settings to one simulation: + +```text +report = simulation.calibrate(budget=60.0) +print(report.describe()) +``` + +or `solprop calibrate --recipe run.toml`. It builds fresh native workspaces of the +simulation in the calling process and times trial steps of its own operators at its +start and a third and two thirds along it, under candidate operation settings; the +simulation itself is not changed. Speed alone never admits a candidate: its steps must +agree with the built-in's to within ten times the run's relative tolerance. The result +is kept under the simulation's *execution descriptor* (its grid, modes, nodes, chunk, +responses, integrator and the process's settings), and every later run or scan point +with the same descriptor applies it, for as long as the machine profile it refined +stays the same. + +## Overrides + +Explicit settings keep their precedence over any profile: `SOLPROP_THREADS`, +`SOLPROP_NATIVE_THREADS` and the pool variables, `OMP_WAIT_POLICY`, `GOMP_SPINCOUNT` and +`KMP_BLOCKTIME`, `SOLPROP_FFTW_PLAN`, `SOLPROP_FFTW_WISDOM` and +`SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES` ([Performance tuning](tuning.md)). A setting +measured under conditions an override changes is not used with it: operation thresholds +fitted under spinning threads give way to the built-in table when you make the threads +sleep. + +The design and its measurements are in +[SEP 46](../../dev/seps/archive/sep-0046-machine-calibration.md). diff --git a/docs/guide/performance/index.md b/docs/guide/performance/index.md index 68df1c223..814c70f53 100644 --- a/docs/guide/performance/index.md +++ b/docs/guide/performance/index.md @@ -4,7 +4,9 @@ Choose a backend that supports the model, then size the calculation and tune its execution. Backend choice changes where and how the calculation runs; the physical and numerical settings still determine what it computes. Installation begins in the [introduction](../../introduction/installation.md). These chapters -cover the native backend, memory, CPU threads, and GPU execution. +cover the native backend, memory, CPU threads, and GPU execution. The best speed on a +given machine comes from [calibrating solprop to it](calibration.md), once, with +`solprop calibrate`. To measure a change's performance, or to see roughly what each backend and method costs, read the developer pages: [Benchmarks](../../dev/benchmarks.md) gives the @@ -15,6 +17,7 @@ measurement rules and indicative results, and ```{toctree} :maxdepth: 1 +calibration backends native building-native diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index 57359cf1f..ee352cd0c 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -1,5 +1,11 @@ # Performance tuning +**Start with the calibration.** `solprop calibrate`, once per machine and build, +measures the settings this page describes on your machine and keeps the result, and +every later run applies it ([Calibrating solprop to your machine](calibration.md)). The +built-in defaults below are what a run uses without a profile; the environment +variables are overrides for special cases, and each takes precedence over a profile. + A solprop run spends its time in a handful of operations repeated at every stage of every step: fast Fourier transforms between time and frequency, dense matrix products between modal amplitudes and the transverse field (for multimode and general modal @@ -17,9 +23,12 @@ Two rules apply throughout: thread count later, but this is not a portable way to reconfigure all pools. Put the variables on the command line or in the job script, not in `os.environ` halfway through a notebook. - **Measure your own problem.** The defaults come from measurements on representative - problems, but the best settings depend on the grid, the number of modes or nodes, the - responses switched on and the machine. Time about ten steps of your real problem - (a short fibre, `n_save=2`) at two or three settings before a long campaign. + problems, and a calibration fits them to the machine, but the best settings also + depend on the grid, the number of modes or nodes and the responses switched on. + `simulation.calibrate()` refines them for one simulation + ([refining one simulation](calibration.md#refining-one-simulation)); otherwise time + about ten steps of your real problem (a short fibre, `n_save=2`) at two or three + settings before a long campaign. None of the settings on this page changes the physics. Thread counts can change the order of floating-point sums inside the transforms, so results from different thread @@ -523,7 +532,10 @@ print( The same record also carries `raman_fft_threads`, `blas_library` and the build profile; `python -m -solprop_native` prints the whole build record without running anything. +solprop_native` prints the whole build record without running anything. Its +`calibration` entry names the machine profile the run applied (or `built-in`), each +operation setting it applied beside the built-in value, and the FFT plans built from the +profile's wisdom ([Calibrating solprop to your machine](calibration.md)). ## Profiling the native backend diff --git a/docs/guide/setup/recipes.md b/docs/guide/setup/recipes.md index e6c6d1a87..7aa13ac6e 100644 --- a/docs/guide/setup/recipes.md +++ b/docs/guide/setup/recipes.md @@ -394,12 +394,20 @@ solprop check rdw.toml # print the issues; exit 1 on an error solprop export rdw.toml --python # print the equivalent script solprop resume rdw.h5 # continue a run from its file alone solprop scan ... # python -m solprop.scan, unchanged +solprop calibrate --show # the native backend's machine profile, if any ``` `python -m solprop` is the same command. `check` exits 0 when the recipe builds (warnings included), 1 when it does not, and 2 when the file cannot be read. `run` and `resume` stop cleanly on Ctrl-C, writing the state, and `solprop resume` continues it ([resuming from the file alone](../results/resuming.md#resuming-from-the-file-alone)). +`solprop calibrate` reads and manages the native backend's machine calibration +([SEP 46](../../dev/seps/archive/sep-0046-machine-calibration.md)): `--show` prints the profile +this allocation would use, with each setting beside the built-in value it replaces, or +says that the built-in table applies; `--export FILE` and `--import FILE` move a profile +between stores, for example to share one per node type of a cluster; and `--clear` +forgets this machine and build's profiles. Profiles live in the platform's user cache +directory, or in `SOLPROP_CACHE_DIR`, and `SOLPROP_CALIBRATION=off` ignores them. ## The example recipes diff --git a/docs/introduction/installation.md b/docs/introduction/installation.md index dae6f0093..feb366931 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -208,6 +208,22 @@ An approximately 1 nJ input and exit confirm this check. Continue with For package import or backend errors, check that the interpreter is the one in which you installed solprop and inspect [backend selection](../guide/performance/backends.md). +## Calibrate to your machine + +The last step of every installation, wheel or source build, laptop or cluster node: + +```sh +solprop calibrate +``` + +In about a minute and a half it measures how the native backend should thread, wait and plan its +transforms on this machine, and keeps the result in your cache directory; every later +run uses it ([Calibrating solprop to your machine](../guide/performance/calibration.md)). +`--quick` takes about 20 s, `--thorough` about five minutes. If you skip it, the first +native run calibrates quickly by itself, with a notice saying so, and the run above may +have done just that. On a cluster, calibrate inside a job on each kind of compute node, +not on a login node. + ## Platforms solprop is developed and tested on macOS (Apple silicon), Linux (x86-64) and Windows diff --git a/gui/tests/conftest.py b/gui/tests/conftest.py index 8386ff693..0476cc913 100644 --- a/gui/tests/conftest.py +++ b/gui/tests/conftest.py @@ -18,6 +18,10 @@ # developer's display is never used. An explicit setting (a debugging session) wins. os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +# A machine calibration (solprop SEP 46) neither runs nor applies here, as in solprop's +# own suite: a first native run would otherwise calibrate, and its notice is an error. +os.environ.setdefault("SOLPROP_CALIBRATION", "off") + def pytest_configure(config: pytest.Config) -> None: """Collect garbage only between tests, as the window collects it between events. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 616fc3f67..812b1768f 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -1078,6 +1078,31 @@ if(SKBUILD) file(WRITE "${PROJECT_BINARY_DIR}/cpu_baseline.txt" "${SOLPROP_NATIVE_CPU_BASELINE}\n") install(FILES "${PROJECT_BINARY_DIR}/cpu_baseline.txt" DESTINATION solprop_native RENAME _cpu_baseline.txt) + # solprop SEP 46: the build's configuration, which a calibration's fingerprint reads + # without loading the extension (and its OpenMP runtime). A variant wheel's builds + # share it; the fingerprint also digests the chosen extension itself. + set(_build_record "{}") + foreach(_field IN ITEMS + "compiler_id;${CMAKE_CXX_COMPILER_ID}" + "compiler_version;${CMAKE_CXX_COMPILER_VERSION}" + "openmp_libraries;${OpenMP_CXX_LIB_NAMES}" + "fft_backend;${SOLPROP_NATIVE_HOST_FFT_BACKEND}" + "blas_backend;${SOLPROP_NATIVE_BLAS_BACKEND}" + "gemm_provider;${SOLPROP_NATIVE_GEMM_PROVIDER}" + "raman_fft_provider;${SOLPROP_NATIVE_RAMAN_FFT_PROVIDER}" + "vector_math;${SOLPROP_NATIVE_VECTOR_MATH}" + "build_profile;${SOLPROP_NATIVE_BUILD_PROFILE}" + "device_backend;${SOLPROP_NATIVE_DEVICE_BACKEND}" + "device_arch;${SOLPROP_NATIVE_DEVICE_ARCH}") + list(POP_FRONT _field _key) + string(REPLACE ";" "," _value "${_field}") + string(REPLACE "\\" "\\\\" _value "${_value}") + string(REPLACE "\"" "\\\"" _value "${_value}") + string(JSON _build_record SET "${_build_record}" "${_key}" "\"${_value}\"") + endforeach() + file(WRITE "${PROJECT_BINARY_DIR}/build_record.json" "${_build_record}\n") + install(FILES "${PROJECT_BINARY_DIR}/build_record.json" DESTINATION solprop_native + RENAME _build.json) # Kokkos, Kokkos Kernels, Kokkos-FFT, SLEEF, nanobind and nanobind's robin_map are # compiled into the extension, so the wheel carries their notices beside it. Kokkos-FFT is # MIT OR Apache-2.0 WITH LLVM-exception; its MIT terms are the ones taken here. diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp index e65f4529f..67ecf730a 100644 --- a/native/include/solprop_native/core.hpp +++ b/native/include/solprop_native/core.hpp @@ -173,27 +173,11 @@ class BackendRefusal : public std::runtime_error { using std::runtime_error::runtime_error; }; -/** Below this many real-side samples per execution a modal or spatial FFTW plan runs - on one thread (`ThreadPolicy::threaded_fft_min_samples` unless set at start). The - crossover is platform-dependent. On Linux at 32768 the four-mode RDW's 32000-sample - coarse batch (n_t 4000) ran serially, its transforms 2.5-3.2 times slower on an - 18-core Cascade Lake socket than the 32768-sample batch's four-thread ones; 16384 - also gains on Zen 3. On an Apple arm64 laptop the serial transforms are the faster - ones at that size, so macOS keeps 32768 until its own crossover is swept. - `__APPLE__` is the only platform switch here and for the stage boundary below: - Windows and linux-aarch64 take the x86 Linux values, which were not measured there - (solprop issue #328). */ -#if defined(__APPLE__) -inline constexpr std::size_t default_threaded_fft_min_samples = 32768; -#else -inline constexpr std::size_t default_threaded_fft_min_samples = 16384; -#endif - -/** Process-start CPU policy; execution instances never exceed the runtime budget. */ +/** Process-start CPU policy (solprop SEP 46 section 5's process settings): the pool + capacities, fixed when the runtime starts. Execution instances never exceed the + runtime budget. */ struct ThreadPolicy { int kernels{}, fft{}, raman_fft{}, raman_tail_fft{}; - // Zero takes `default_threaded_fft_min_samples`. - std::size_t threaded_fft_min_samples{}; // The field plasma column's band form (solprop SEP 25 section 6.7.6): negative takes // the build's (tiled on a device, the team column on the host), zero the team // column, and N > 0 the tiled column with host teams of N threads. @@ -238,113 +222,90 @@ enum class CpuOperation { modal, adams }; +inline constexpr std::size_t cpu_operation_count = + static_cast(CpuOperation::adams) + 1; /** Process-start pool supplying an operation's capacity and explicit override. */ enum class CpuPool { kernels, fft, raman_fft, raman_tail_fft }; -// Shared CPU policy; solprop docs/dev/benchmarks.md, section -// "Kokkos CPU operation-size threading", records the complete windows and sweeps. -// Work is elements for stages/copies, active columns * fine samples for responses, -// rows * inner * samples for dense products, active bins * modes^3 for matrix -// functions, elements * history rows for the exponential Adams trial's passes, and -// actual real-side batch samples for FFT/Raman plans (including tails). -// An 8196-element RDW stage needs the full pool in the server calibration; putting its -// reductions on a separate width loses that gain. In contrast, an 8192-element temporal -// copy must stay serial: threading it changes averaged helium from 0.74 to 1.27 s in -// that calibration. Native products stay serial below the conservative one-million-work -// crossover: 2x2x8192 favours serial on both hosts, whereas 33x4x8192 favours the full -// pool. Intermediate native-product widths did not establish a complete-window benefit. -// General-modal synthesis/projection have dedicated team kernels, with work -// active nodes * components * spectral samples * modes. Keep their measured -// full-width threshold separate: serialising these kernels regresses complete -// windows on both hosts, despite the generic small-GEMM crossover above. -// Responses retain four threads for many short columns (33x4096), while long -// 65536-sample columns enable the full pool without threading small copy kernels. -// Wider chunks also need the full pool: switching between a four-thread response -// and a full-width FFT regressed the Cartesian filament on the server. The column -// boundary retains the measured 33-column regime and includes 44-column tails. -// This is a construction-time shape rule, shared by both hosts and all responses. -// The stage boundary is platform-dependent. At 8192, just under the calibrated -// 8196-element RDW stage, the same case on a 2-3-5-smooth grid (n_t 4000, 8004 -// elements) ran its stage entry serially on Linux: 708 us against 59 us at full width -// on an 18-core Cascade Lake socket, 1.8 times the whole evaluation of n_t 4096. At -// 4096 (with the FFT boundary at 16384) n_t 4000 costs no more than 4096 there and on -// Zen 3; at 1024 a 1351-element SSFS stage regressed. On an Apple arm64 laptop the -// serial stage is the faster one at 8004 elements (and 4096's threaded one is already -// dearer than 4000's serial), so macOS keeps 8192 until its own crossover is swept. -// Windows and linux-aarch64 take the Linux value, unmeasured there. -#if defined(__APPLE__) -inline constexpr std::size_t stage_thread_min_elements = 8192; -#else -inline constexpr std::size_t stage_thread_min_elements = 4096; -#endif -inline constexpr std::size_t copy_thread_min_elements = 65536; -// The 38691-work scalar-wall flow needs full width: its serial block functions -// took 2.39 s versus 0.13 s with the full pool in the server window profile. -inline constexpr std::size_t flow_thread_min_elements = 32768; -inline constexpr std::size_t modal_thread_min_elements = 65536; -inline constexpr std::size_t response_thread_min_elements = 32768; -inline constexpr std::size_t dense_thread_min_elements = 1048576; -inline constexpr std::size_t raman_thread_min_elements = 65536; -// The exponential Adams trial's two passes cost about ten stage entries an element, -// so they thread on fields the stage keeps serial; its work is elements * history -// rows (seven for the default fifth order). Swept on single-column capillaries of 401 -// to 10241 elements with the passes forced to 1-16 threads of a 16-thread process, -// idle threads sleeping: on 16 cores of a dual EPYC 7643 four threads lost at 4487 -// (0.8x) and gained 1.1-2.1x from 5607 to 71687, also with other jobs on the node, -// while 16 lost to four on helium up to 17927 and to one at 8967 (a fork and join of -// 16 costs 100-200 us); an 18-core Apple M5 Max gained at four from 4487 and at 16 -// over four only at 71687 (docs/dev/benchmarks.md, "Kokkos CPU operation-size -// threading"). -inline constexpr std::size_t adams_thread_min_elements = 8192; -inline constexpr std::size_t adams_full_thread_elements = 65536; -inline constexpr std::size_t stage_full_thread_elements = stage_thread_min_elements; -inline constexpr std::size_t response_full_thread_elements = 196608; -inline constexpr std::size_t response_full_thread_samples = 65536; -inline constexpr std::size_t response_full_thread_columns = 40; -inline constexpr std::size_t dense_full_thread_elements = 1048576; -inline constexpr std::size_t fft_full_thread_elements = 131072; -inline constexpr std::size_t raman_full_thread_elements = 262144; -// A kernel pool that holds a full-width instance gives it to medium-width kernels too -// (CpuExecutionPool::get): GNU OpenMP ends surplus threads when a team narrows, and on -// Windows, with LLVM's libomp, the wider team still ran h2_raman_averaged 1.1 times -// faster at eight threads. Not on macOS: on an Apple M5 Max, whose 18 cores are of two -// kinds, the promoted passes took argon_blueshift from 3.0 to 3.2 s at 18 threads. -inline constexpr bool stable_kernel_team = -#if defined(__APPLE__) - false; -#else - true; -#endif -inline constexpr int cpu_medium_threads = 4; -// The thresholds above were swept with idle threads asleep, a fork and join of 16 -// threads costing 100-200 us. On Apple silicon, while LLVM's runtime spins within -// native calls (`configure_native_spin`, solprop ADR-071) on a team of at most -// `spinning_threshold_max_team` threads, a region costs about a microsecond, and every -// threshold is divided by this. On an Apple M5 Max at six threads, on top of the spin: -// the Raman GNLSE 0.44 of its window, Raman hydrogen 0.89, comb-to-continuum 0.92, the -// tracking cases 0.87 on geometric mean, none slower; its top-tier cluster shares one -// L2 cache. On two Intel Xeon Gold 6240, whose cores share only the L3, a sixteenth ran -// the averaged argon and hydrogen 1.4-1.7 times slower at 4, 8, 18 and 36 threads with -// either runtime, and a quarter lost on 16 cores of an AMD EPYC 7763 (ADR-073). The -// grain belongs to the machine: a calibration at start-up would be the general form. -inline constexpr std::size_t spinning_threshold_divisor = 16; -inline constexpr int spinning_threshold_max_team = 8; -// A compact problem (`compact_problem`: one column on one node with fewer than -// `compact_max_fine_samples` fine samples, the GNLSE and the mode-averaged capillary -// among them) runs its kernels on a team of at most `cpu_compact_threads` threads and -// its threaded transforms on that whole team. Its stage of a few thousand to 16384 bins -// otherwise woke the whole pool for one short pass between serial transforms: on 16 -// cores of an Intel Xeon Gold 6240 the main thread then ran at about 2.7 instead of 3.5 -// GHz, its arrays came back from other cores' caches, and gnlse_raman_shock took as -// long on 16 threads as on one. FFTW's threaded loops take the runtime's whole team, so -// a threaded transform beside the narrower kernel team would make GNU OpenMP end and -// restart threads at every pair: compact transforms run their loops on the team -// through FFTW's callback (`CpuTeamScope`), or stay serial without it (solprop -// docs/dev/benchmarks.md, "Compact CPU teams"). -inline constexpr int cpu_compact_threads = 8; -inline constexpr std::size_t compact_max_fine_samples = 65536; +/** One operation class's grain: below `threshold` work it runs on one thread, below + `full` on the medium width, and from `full` on its pool's capacity. Work is + elements for stages and copies, active columns * fine samples for responses, + rows * inner * samples for dense products, active bins * modes^3 for matrix + functions, elements * history rows for the exponential Adams trial's passes, and + actual real-side batch samples for FFT and Raman plans (tails included); the FFT + class's threshold is where a modal or spatial FFTW plan starts to thread. */ +struct OperationGrain { + std::size_t threshold{}, full{}; + bool operator==(const OperationGrain&) const = default; +}; + +/** A workspace's CPU execution settings (solprop SEP 46 section 5's workspace + settings), copied into the workspace when it is built and never read from anywhere + global, so two live workspaces may hold different ones. + + Native code holds no values for them (solprop_native's built-in table, + `_execution.py`, or a machine calibration supplies every field, with the + measurements behind each built-in value) and refuses a record that leaves one + unset (`require_cpu_execution`). + + - `grain`, indexed by `CpuOperation`. + - A response of `response_full_samples` fine samples or more, or of + `response_full_columns` columns or more, takes the full pool at any size above + its threshold. + - `medium_threads`: the medium width. + - A compact problem (`compact_problem`: one column on one node with fewer than + `compact_max_fine_samples` fine samples) runs its kernels on a team of at most + `compact_threads` and its threaded transforms on that whole team. + - `stable_kernel_team`: once a kernel pool holds a full-width instance, a + medium-width kernel takes it too (`CpuExecutionPool::get`). */ +struct CpuExecution { + std::array grain{}; + std::size_t response_full_samples{}, response_full_columns{}; + int medium_threads{}, compact_threads{}; + std::size_t compact_max_fine_samples{}; + bool stable_kernel_team{}; + bool operator==(const CpuExecution&) const = default; + [[nodiscard]] const OperationGrain& + operator[](CpuOperation operation) const noexcept { + return grain[static_cast(operation)]; + } +}; + +/** Throw `std::invalid_argument` unless every threshold, bound and width of + `execution` is positive and each class's full size is at least its threshold. */ +void require_cpu_execution(const CpuExecution& execution); + +/** How a workspace plans its host FFTW transforms (solprop SEP 46 phase 3). + + - `legacy`: the plan policy of `fftw_seed_effort`, with the process's wisdom. + - `profile`: a machine calibration's. The workspace forgets every wisdom before it + plans and after; a transform whose exact plan the profile holds imports that + plan's wisdom and is seeded with `FFTW_WISDOM_ONLY` (a plan it no longer builds + is listed in `missing`, and planned at estimate); any other is seeded at its + group's effort, as the policy would, but with no wisdom from elsewhere. + - `capture`: each transform is seeded alone, from no wisdom, at `capture_effort` + within `time_limit` seconds (none when not positive), and its wisdom is kept in + `captured`, for a calibration to time and store. + + A transform's group is `rows` (one thread), `threaded_rows` or `planes`; its key + names its kind, sizes, strides, placement, alignment and threads + (`fft_plan_key`). Efforts are "estimate", "measure" or "patient". */ +struct FftPlanning { + enum class Mode { legacy, profile, capture }; + Mode mode{Mode::legacy}; + std::string rows{"measure"}, threaded_rows{"estimate"}, planes{"measure"}; + std::map wisdom; + std::string capture_effort{"measure"}; + double time_limit{-1.0}; + // What planning found: each transform's group, the wisdom captured, and the + // profile's plans that no longer build. + std::map groups, captured; + std::vector missing; +}; + /** Choose a width without changing the runtime or a vendor library's pool. */ -[[nodiscard]] int operation_threads(const ThreadPolicy& policy, CpuPool pool, +[[nodiscard]] int operation_threads(const ThreadPolicy& policy, + const CpuExecution& execution, CpuPool pool, CpuOperation operation, std::size_t elements, std::size_t fine_samples = 0); @@ -359,12 +320,16 @@ inline constexpr bool cpu_size_policy_enabled = false; /** Own execution instances constructed only while a workspace and its plans form. */ class CpuExecutionPool { public: - explicit CpuExecutionPool(CpuPool pool) : pool_(pool) {} + /** A pool of `pool`'s capacity choosing widths by `execution`, which it copies. */ + CpuExecutionPool(CpuPool pool, const CpuExecution& execution) + : pool_(pool), execution_(execution) {} const ExecutionSpace& get(CpuOperation operation, std::size_t elements, std::size_t fine_samples = 0); - /** Size a compact problem's instances (`cpu_compact_threads`), before any `get`; - explicit pool requests still apply as given. */ + /** Size a compact problem's instances (`CpuExecution::compact_threads`), before any + `get`; explicit pool requests still apply as given. */ void compact() noexcept { compact_ = cpu_size_policy_enabled; } + /** The execution settings this pool chooses widths by. */ + [[nodiscard]] const CpuExecution& execution() const noexcept { return execution_; } /** The widest instance handed out so far; zero before any. */ [[nodiscard]] int widest() const noexcept { return instances_.empty() ? 0 : instances_.rbegin()->first; @@ -374,6 +339,7 @@ class CpuExecutionPool { private: CpuPool pool_; + CpuExecution execution_; bool compact_{}; std::map instances_; CpuSizing sizing_; @@ -524,6 +490,11 @@ struct Problem { std::size_t n_pol_transform{1}; std::size_t n_nodes{1}; std::size_t node_chunk{1}; + // How the workspace's CPU operations thread (solprop SEP 46): set by the caller, + // refused when unset (`require_cpu_execution`). + CpuExecution cpu_execution{}; + // How the workspace plans its host transforms (solprop SEP 46 phase 3). + FftPlanning fft_planning{}; std::size_t n_stages{}; // solprop SEP 24 phase 3 (kernel schema 32): the low-storage RK4IP march. With it no // Runge-Kutta view is allocated (stages, accumulators, accept buffer, residual, @@ -797,9 +768,10 @@ struct TrialScalars { /** Which general z table a call names (solprop SEP 29 G1). */ enum class TableKind { phi, prefactor, rate, flux }; -/** Whether `problem` runs on a compact CPU team (`cpu_compact_threads`): one column - on one node, fewer than `compact_max_fine_samples` fine samples, and neither the - general modal route, a coupled flow nor a separable geometry. */ +/** Whether `problem` runs on a compact CPU team (`CpuExecution::compact_threads`): one + column on one node, fewer than its record's `compact_max_fine_samples` fine + samples, and neither the general modal route, a coupled flow nor a separable + geometry. */ [[nodiscard]] bool compact_problem(const Problem& problem) noexcept; class GeneralWorkspace; @@ -1009,6 +981,10 @@ class GeneralState { [[nodiscard]] std::map dispersive_path() const; [[nodiscard]] std::map memory_ledger() const; [[nodiscard]] CpuSizing execution_sizing() const; + /** The CPU execution record the workspace's pools choose widths by. */ + [[nodiscard]] const CpuExecution& cpu_execution() const; + /** The workspace's FFT planning, with what its planning found. */ + [[nodiscard]] const FftPlanning& fft_planning() const; [[nodiscard]] double planning_seconds() const noexcept; [[nodiscard]] double raman_planning_seconds() const noexcept; [[nodiscard]] std::string raman_fft_provider() const; diff --git a/native/include/solprop_native/fft_plan.hpp b/native/include/solprop_native/fft_plan.hpp index 84c9ec1d2..988442f47 100644 --- a/native/include/solprop_native/fft_plan.hpp +++ b/native/include/solprop_native/fft_plan.hpp @@ -7,9 +7,13 @@ #include #include +#include "solprop_native/core.hpp" + #if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) +#include #include #include +#include #include #include #include @@ -48,6 +52,136 @@ inline unsigned fftw_seed_effort(const FftShape shape, const int threads) { std::string(effort) + "'"); } +// An effort's FFTW flag. +inline unsigned fftw_effort_flag(const std::string_view effort) { + if (effort == "estimate") + return FFTW_ESTIMATE; + if (effort == "measure") + return FFTW_MEASURE; + if (effort == "patient") + return FFTW_PATIENT; + throw std::invalid_argument("an FFTW effort is estimate, measure or patient, not '" + + std::string(effort) + "'"); +} + +namespace detail { +// The planning of the workspace this thread is building (`FftPlanningScope`). +inline thread_local FftPlanning* planning = nullptr; +} // namespace detail + +/** While it lives, the calling thread's seeds follow `planning` (solprop SEP 46). In + profile and capture modes FFTW's process-wide wisdom is forgotten at the start and + at the end, so that no wisdom from an earlier workspace, a file or another + candidate reaches this one, and none of this one's outlives it. Planning holds the + GIL, which serialises FFTW's planner. */ +class FftPlanningScope { +public: + explicit FftPlanningScope(FftPlanning& planning) + : saved_(detail::planning), forgets_(planning.mode != FftPlanning::Mode::legacy) { + detail::planning = &planning; + if (forgets_) { + fftw_init_threads(); + fftw_forget_wisdom(); + } + } + ~FftPlanningScope() { + if (forgets_) + fftw_forget_wisdom(); + detail::planning = saved_; + } + FftPlanningScope(const FftPlanningScope&) = delete; + FftPlanningScope& operator=(const FftPlanningScope&) = delete; + FftPlanningScope(FftPlanningScope&&) = delete; + FftPlanningScope& operator=(FftPlanningScope&&) = delete; + +private: + FftPlanning* saved_; + bool forgets_; +}; + +// The flags a transform of `key` and `shape` on `threads` threads is seeded with, or +// none to leave it to Kokkos-FFT's estimate plan: the policy, or the workspace's +// planning (`FftPlanning`), which this records the transform in. Capture forgets the +// wisdom before the seed and sets the planner's time limit; profile imports the +// transform's own wisdom. +inline std::optional begin_seed(const std::string& key, const FftShape shape, + const int threads) { + FftPlanning* planning = detail::planning; + if (planning == nullptr || planning->mode == FftPlanning::Mode::legacy) { + const unsigned effort = fftw_seed_effort(shape, threads); + if (effort == FFTW_ESTIMATE) + return std::nullopt; + return effort; + } + const char* group = shape == FftShape::planes ? "planes" + : threads > 1 ? "threaded_rows" + : "rows"; + planning->groups[key] = group; + if (planning->mode == FftPlanning::Mode::capture) { + const unsigned effort = fftw_effort_flag(planning->capture_effort); + if (effort == FFTW_ESTIMATE) + return std::nullopt; + fftw_forget_wisdom(); + fftw_set_timelimit(planning->time_limit > 0.0 ? planning->time_limit + : FFTW_NO_TIMELIMIT); + return effort; + } + const std::string& chosen = shape == FftShape::planes ? planning->planes + : threads > 1 ? planning->threaded_rows + : planning->rows; + const unsigned effort = fftw_effort_flag(chosen); + if (const auto found = planning->wisdom.find(key); found != planning->wisdom.end()) { + if (fftw_import_wisdom_from_string(found->second.c_str()) == 0) { + planning->missing.push_back(key); + return std::nullopt; + } + return effort | FFTW_WISDOM_ONLY; + } + if (effort == FFTW_ESTIMATE) + return std::nullopt; + return effort; +} + +// Finish a seed begun with `flags`: a capture keeps the transform's wisdom and lifts +// the time limit; a profile's plan that no longer builds from its wisdom is recorded, +// and left to the estimate plan. Any other failure to plan is an error. +inline void end_seed(const std::string& key, const unsigned flags, const bool planned) { + FftPlanning* planning = detail::planning; + const bool capture = + planning != nullptr && planning->mode == FftPlanning::Mode::capture; + if (capture) + fftw_set_timelimit(FFTW_NO_TIMELIMIT); + if (!planned) { + if ((flags & FFTW_WISDOM_ONLY) != 0U && planning != nullptr) { + planning->missing.push_back(key); + return; + } + throw std::runtime_error("FFTW could not plan the transform " + key); + } + if (capture) { + const auto release = [](char* pointer) { std::free(pointer); }; + const std::unique_ptr text(fftw_export_wisdom_to_string(), + release); + planning->captured[key] = text ? std::string(text.get()) : std::string(); + } +} + +// A transform's identity for its wisdom: its kind, the sizes and distances, in- or +// out-of-place, the arrays' alignment and the threads (solprop SEP 46 section 6.7). +inline std::string fft_plan_key(const std::string_view kind, const std::string& sizes, + const int howmany, const int input_distance, + const int output_distance, const void* input, + const void* output, const int threads) { + const auto alignment = [](const void* pointer) { + return std::to_string( + fftw_alignment_of(static_cast(const_cast(pointer)))); + }; + return std::string(kind) + ":" + sizes + ":" + std::to_string(howmany) + ":" + + std::to_string(input_distance) + ":" + std::to_string(output_distance) + ":" + + (input == output ? "in" : "out") + ":" + alignment(input) + ":" + + alignment(output) + ":t" + std::to_string(threads); +} + // Seed only last-axis, contiguous, out-of-place row transforms. Planning may // destroy BOTH buffers: callers must invoke this before filling working data, and // before the transform's own Kokkos-FFT plan exists, which would otherwise keep @@ -56,9 +190,6 @@ inline unsigned fftw_seed_effort(const FftShape shape, const int threads) { template void seed_row_fft_wisdom(const Execution& execution, const Input& input, const Output& output, const KokkosFFT::Direction direction) { - const auto effort = fftw_seed_effort(FftShape::rows, execution.concurrency()); - if (effort == FFTW_ESTIMATE) - return; constexpr bool real_input = std::is_same_v; constexpr bool real_output = @@ -76,7 +207,19 @@ void seed_row_fft_wisdom(const Execution& execution, const Input& input, const int rows = static_cast(input.extent(0)); const int input_width = static_cast(input.extent(1)); const int output_width = static_cast(output.extent(1)); + const bool forward = direction == KokkosFFT::Direction::forward; + const std::string key = + fft_plan_key(real_input ? "r2c" + : real_output ? "c2r" + : forward ? "c2c-forward" + : "c2c-backward", + std::to_string(samples), rows, input_width, output_width, + input.data(), output.data(), execution.concurrency()); fftw_init_threads(); + const auto chosen = begin_seed(key, FftShape::rows, execution.concurrency()); + if (!chosen) + return; + const unsigned effort = *chosen; fftw_plan_with_nthreads(execution.concurrency()); fftw_plan seeded = nullptr; if constexpr (real_input) @@ -92,12 +235,10 @@ void seed_row_fft_wisdom(const Execution& execution, const Input& input, seeded = fftw_plan_many_dft( 1, &samples, rows, reinterpret_cast(input.data()), &input_width, 1, input_width, reinterpret_cast(output.data()), &output_width, - 1, output_width, - direction == KokkosFFT::Direction::forward ? FFTW_FORWARD : FFTW_BACKWARD, - effort); - if (seeded == nullptr) - throw std::runtime_error("FFTW could not plan a contiguous row transform"); - fftw_destroy_plan(seeded); + 1, output_width, forward ? FFTW_FORWARD : FFTW_BACKWARD, effort); + end_seed(key, effort, seeded != nullptr); + if (seeded != nullptr) + fftw_destroy_plan(seeded); } // FFTW's process-wide wisdom as text, for solprop's opt-in SOLPROP_FFTW_WISDOM: the @@ -125,6 +266,14 @@ inline bool import_fftw_wisdom(const std::string& text) { return fftw_import_wisdom_from_string(text.c_str()) != 0; } +} // namespace solprop_native +#else +namespace solprop_native { +// Device builds plan with cuFFT or rocFFT, which keep no wisdom. +class FftPlanningScope { +public: + explicit FftPlanningScope(FftPlanning& /*planning*/) noexcept {} +}; } // namespace solprop_native #endif diff --git a/native/include/solprop_native/general_modal.hpp b/native/include/solprop_native/general_modal.hpp index 97d7435fb..e0ccd8ee6 100644 --- a/native/include/solprop_native/general_modal.hpp +++ b/native/include/solprop_native/general_modal.hpp @@ -226,9 +226,8 @@ class GeneralModalSource { * response. */ bool real_signal_{}; double epsilon0_{}; - CpuExecutionPool kernel_pool_{CpuPool::kernels}, fft_pool_{CpuPool::fft}; - CpuExecutionPool raman_pool_{CpuPool::raman_fft}, - raman_tail_pool_{CpuPool::raman_tail_fft}; + // The owner's record (`Problem::cpu_execution`), copied at construction. + CpuExecutionPool kernel_pool_, fft_pool_, raman_pool_, raman_tail_pool_; ExecutionSpace kernels_, transforms_, delayed_transforms_; ExecutionSpace response_, tail_response_, dense_, tail_dense_, maps_; const ExecutionSpace& response_for(std::size_t count) const { diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp index 8bd3f7bcc..2484cb219 100644 --- a/native/src/bindings.cpp +++ b/native/src/bindings.cpp @@ -174,8 +174,159 @@ native::TableKind table_kind(const std::string& name) { "'; expected 'phi', 'prefactor', 'rate' or 'flux'"); } +// solprop SEP 46: a workspace's CPU execution record, as solprop_native's +// `_execution.ExecutionRecord.native()` writes it: each operation's threshold and full +// size, then the response bounds, widths and rules, every key required. +constexpr std::array cpu_operation_names{ + "stage", "response", "dense", "fft", "raman", "copy", "flow", "modal", "adams"}; +constexpr std::array cpu_execution_scalars{ + "response_full_samples", "response_full_columns", "medium_threads", + "compact_threads", "compact_max_fine_samples", "stable_kernel_team"}; + +native::CpuExecution cpu_execution_from(const nb::dict& values) { + native::CpuExecution result; + const std::size_t expected = + 2 * native::cpu_operation_count + cpu_execution_scalars.size(); + if (values.size() != expected) + throw std::invalid_argument("a CPU execution record has " + + std::to_string(expected) + " fields, got " + + std::to_string(values.size())); + const auto field = [&values](const std::string& name) { + if (!values.contains(name.c_str())) + throw std::invalid_argument("a CPU execution record misses a field: " + name); + return values[name.c_str()]; + }; + try { + for (std::size_t index = 0; index < native::cpu_operation_count; ++index) { + const std::string name = cpu_operation_names[index]; + result.grain[index] = {nb::cast(field(name + "_threshold")), + nb::cast(field(name + "_full"))}; + } + result.response_full_samples = + nb::cast(field("response_full_samples")); + result.response_full_columns = + nb::cast(field("response_full_columns")); + result.medium_threads = nb::cast(field("medium_threads")); + result.compact_threads = nb::cast(field("compact_threads")); + result.compact_max_fine_samples = + nb::cast(field("compact_max_fine_samples")); + result.stable_kernel_team = nb::cast(field("stable_kernel_team")); + } catch (const nb::cast_error&) { + throw std::invalid_argument( + "a CPU execution record holds nonnegative integers and one bool"); + } + native::require_cpu_execution(result); + return result; +} + +// solprop SEP 46 phase 3: a workspace's FFT planning, as solprop_native's backend +// writes it: its mode, each group's effort, the profile's wisdom by plan key, and a +// capture's effort and time limit; absent, the plan policy (`legacy`). +[[maybe_unused]] native::FftPlanning fft_planning_from(const nb::dict& values) { + native::FftPlanning result; + try { + const auto mode = nb::cast(values["mode"]); + if (mode == "legacy") + result.mode = native::FftPlanning::Mode::legacy; + else if (mode == "profile") + result.mode = native::FftPlanning::Mode::profile; + else if (mode == "capture") + result.mode = native::FftPlanning::Mode::capture; + else + throw std::invalid_argument("an FFT planning mode is legacy, profile or capture, " + "not '" + + mode + "'"); + if (values.contains("efforts")) { + const auto efforts = nb::cast(values["efforts"]); + for (auto [name, effort] : efforts) { + const auto group = nb::cast(name); + auto text = nb::cast(effort); +#if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) + static_cast(native::fftw_effort_flag(text)); +#endif + if (group == "rows") + result.rows = std::move(text); + else if (group == "threaded_rows") + result.threaded_rows = std::move(text); + else if (group == "planes") + result.planes = std::move(text); + else + throw std::invalid_argument("an FFT planning group is rows, threaded_rows " + "or planes, not '" + + group + "'"); + } + } + if (values.contains("wisdom")) + for (auto [key, text] : nb::cast(values["wisdom"])) + result.wisdom[nb::cast(key)] = nb::cast(text); + if (values.contains("capture_effort")) { + result.capture_effort = nb::cast(values["capture_effort"]); +#if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) + static_cast(native::fftw_effort_flag(result.capture_effort)); +#endif + } + if (values.contains("time_limit")) + result.time_limit = nb::cast(values["time_limit"]); + } catch (const nb::cast_error&) { + throw std::invalid_argument("an FFT planning record has the wrong types"); + } + return result; +} + +nb::dict fft_planning_dict(const native::FftPlanning& planning) { + nb::dict result; + nb::dict groups; + for (const auto& [key, group] : planning.groups) + groups[key.c_str()] = group; + nb::dict captured; + for (const auto& [key, text] : planning.captured) + captured[key.c_str()] = text; + nb::list missing; + for (const auto& key : planning.missing) + missing.append(key); + result["groups"] = groups; + result["captured"] = captured; + result["missing"] = missing; + return result; +} + +nb::dict cpu_execution_dict(const native::CpuExecution& execution) { + nb::dict result; + for (std::size_t index = 0; index < native::cpu_operation_count; ++index) { + const std::string name = cpu_operation_names[index]; + result[(name + "_threshold").c_str()] = execution.grain[index].threshold; + result[(name + "_full").c_str()] = execution.grain[index].full; + } + result["response_full_samples"] = execution.response_full_samples; + result["response_full_columns"] = execution.response_full_columns; + result["medium_threads"] = execution.medium_threads; + result["compact_threads"] = execution.compact_threads; + result["compact_max_fine_samples"] = execution.compact_max_fine_samples; + result["stable_kernel_team"] = execution.stable_kernel_team; + return result; +} + native::Problem problem_from(const nb::dict& values) { native::Problem result; + if (!values.contains("cpu_execution")) + throw std::invalid_argument("a native problem needs its CPU execution record " + "(cpu_execution, solprop SEP 46)"); + try { + result.cpu_execution = + cpu_execution_from(nb::cast(values["cpu_execution"])); + } catch (const nb::cast_error&) { + throw std::invalid_argument("a native problem's cpu_execution must be a dict"); + } +#if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) + if (values.contains("fft_planning")) { + try { + result.fft_planning = + fft_planning_from(nb::cast(values["fft_planning"])); + } catch (const nb::cast_error&) { + throw std::invalid_argument("a native problem's fft_planning must be a dict"); + } + } +#endif try { if (values.contains("n_x")) { result.n_x = nb::cast(values["n_x"]); @@ -850,7 +1001,6 @@ RealOutput real_output(std::vector values, const std::size_t rows) { void initialize_kokkos(const int threads, const int kernels, const int fft, const int raman_fft, const int raman_tail_fft, - const std::size_t threaded_fft_min_samples, const int band_tiles) { if (threads < 1) throw std::invalid_argument("thread count must be positive"); @@ -867,7 +1017,7 @@ void initialize_kokkos(const int threads, const int kernels, const int fft, "Kokkos was already initialized with another thread count"); } native::configure_thread_policy( - {kernels, fft, raman_fft, raman_tail_fft, threaded_fft_min_samples, band_tiles}); + {kernels, fft, raman_fft, raman_tail_fft, band_tiles}); } // ------------------------------------------------------------- the native Adams march @@ -1984,6 +2134,14 @@ class BoundState { require_open(); return general_state_->execution_sizing(); } + nb::dict cpu_execution() const { + require_open(); + return cpu_execution_dict(general_state_->cpu_execution()); + } + nb::dict fft_planning() const { + require_open(); + return fft_planning_dict(general_state_->fft_planning()); + } double planning_seconds() const { require_open(); return general_state_->planning_seconds(); @@ -2399,26 +2557,8 @@ nb::dict build_record() { result["raman_fft_threads"] = threads.raman_fft; result["raman_tail_fft_threads"] = threads.raman_tail_fft; #endif - result["threaded_fft_min_samples"] = threads.threaded_fft_min_samples; result["cpu_size_policy_enabled"] = native::cpu_size_policy_enabled; result["explicit_thread_pools"] = threads.explicit_pools; - result["stage_thread_min_elements"] = native::stage_thread_min_elements; - result["copy_thread_min_elements"] = native::copy_thread_min_elements; - result["flow_thread_min_elements"] = native::flow_thread_min_elements; - result["modal_thread_min_elements"] = native::modal_thread_min_elements; - result["response_thread_min_elements"] = native::response_thread_min_elements; - result["dense_thread_min_elements"] = native::dense_thread_min_elements; - result["raman_thread_min_elements"] = native::raman_thread_min_elements; - result["stage_full_thread_elements"] = native::stage_full_thread_elements; - result["response_full_thread_elements"] = native::response_full_thread_elements; - result["response_full_thread_samples"] = native::response_full_thread_samples; - result["response_full_thread_columns"] = native::response_full_thread_columns; - result["dense_full_thread_elements"] = native::dense_full_thread_elements; - result["fft_full_thread_elements"] = native::fft_full_thread_elements; - result["raman_full_thread_elements"] = native::raman_full_thread_elements; - result["adams_thread_min_elements"] = native::adams_thread_min_elements; - result["adams_full_thread_elements"] = native::adams_full_thread_elements; - result["cpu_medium_threads"] = native::cpu_medium_threads; result["band_column"] = native::band_tiled(threads) ? "tiled" : "team"; result["band_tile_threads"] = native::band_tile_threads(threads); result["cxx_standard"] = 20; @@ -2494,7 +2634,7 @@ NB_MODULE(_core, module_) { module_.doc() = "Private nanobind seam for solprop-native"; module_.def("initialize_runtime", &initialize_kokkos, "threads"_a, "kernels"_a = 0, "fft"_a = 0, "raman_fft"_a = 0, "raman_tail_fft"_a = 0, - "threaded_fft_min_samples"_a = 0, "band_tiles"_a = -1); + "band_tiles"_a = -1); module_.def("fit_blas_threads", &fit_blas_threads, "threads"_a); module_.def("register_host_blas", ®ister_host_blas); // solprop SEP 45 phase 1: the boundary watch's predictions, for solprop's tests to @@ -2587,6 +2727,16 @@ NB_MODULE(_core, module_) { "warm"_a, "repeats"_a); // The calling thread's OpenMP default as a native call sees it (solprop issue #130). module_.def("scoped_openmp_default", &native::scoped_openmp_default); +#if !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) + // solprop SEP 46: forget FFTW's process-wide wisdom, for a test of the planning + // transaction. + module_.def("forget_fftw_wisdom", [] { + fftw_init_threads(); + fftw_forget_wisdom(); + }); +#else + module_.def("forget_fftw_wisdom", [] {}); +#endif module_.def("configure_native_spin", &native::configure_native_spin, "milliseconds"_a); module_.def("scoped_block_time", &native::scoped_block_time); @@ -3566,6 +3716,8 @@ NB_MODULE(_core, module_) { .def_prop_ro("raman_fft_provider", [](BoundState& self) { return self.raman_fft_provider(); }) .def_prop_ro("execution_sizing", &BoundState::execution_sizing) + .def_prop_ro("cpu_execution", &BoundState::cpu_execution) + .def_prop_ro("fft_planning", &BoundState::fft_planning) .def_prop_ro("raman_fft_tasks", [](BoundState& self) { return self.raman_fft_tasks(); }) .def_prop_ro("raman_planning_seconds", diff --git a/native/src/core.cpp b/native/src/core.cpp index cdb2eae69..dbeddd341 100644 --- a/native/src/core.cpp +++ b/native/src/core.cpp @@ -24,19 +24,13 @@ ThreadPolicy configured_threads; // The runtime's width, which every unscoped OpenMP region on any thread takes // (`CpuTeamScope`): Kokkos sets the default only on the thread that initialised it. std::atomic runtime_width{0}; -#if defined(KOKKOS_ENABLE_OPENMP) && !defined(KOKKOS_ENABLE_CUDA) && \ - !defined(KOKKOS_ENABLE_HIP) -// What the operation thresholds are divided by (`spinning_threshold_divisor` while -// LLVM's runtime spins within native calls, `configure_native_spin`; else one). -std::atomic threshold_divisor{1}; -#endif } // namespace ThreadPolicy thread_policy() { if (configured_threads.kernels != 0) return configured_threads; const int count = Kokkos::DefaultHostExecutionSpace().concurrency(); - return {count, count, count, count, default_threaded_fft_min_samples, -1}; + return {count, count, count, count, -1}; } void configure_thread_policy(ThreadPolicy requested) { @@ -54,8 +48,6 @@ void configure_thread_policy(ThreadPolicy requested) { } if (requested.band_tiles < -1) throw std::invalid_argument("the band column request must be -1, 0 or positive"); - if (requested.threaded_fft_min_samples == 0) - requested.threaded_fft_min_samples = default_threaded_fft_min_samples; if (configured_threads.kernels != 0 && configured_threads != requested) throw std::runtime_error("native CPU thread policy was already initialized"); configured_threads = requested; @@ -73,9 +65,23 @@ ExecutionSpace execution_instance(const int threads) { return ExecutionSpace(); } -int operation_threads(const ThreadPolicy& policy, const CpuPool pool, - const CpuOperation operation, const std::size_t elements, - const std::size_t fine_samples) { +void require_cpu_execution(const CpuExecution& execution) { + for (const OperationGrain& grain : execution.grain) + if (grain.threshold == 0 || grain.full < grain.threshold) + throw std::invalid_argument( + "a CPU execution record needs a positive threshold and a full size at least " + "as large for every operation (solprop_native's _execution table)"); + if (execution.response_full_samples == 0 || execution.response_full_columns == 0 || + execution.medium_threads < 1 || execution.compact_threads < 1 || + execution.compact_max_fine_samples == 0) + throw std::invalid_argument( + "a CPU execution record needs positive response bounds, widths and compact " + "bound (solprop_native's _execution table)"); +} + +int operation_threads(const ThreadPolicy& policy, const CpuExecution& execution, + const CpuPool pool, const CpuOperation operation, + const std::size_t elements, const std::size_t fine_samples) { const int capacities[] = {policy.kernels, policy.fft, policy.raman_fft, policy.raman_tail_fft}; const auto index = static_cast(pool); @@ -84,58 +90,18 @@ int operation_threads(const ThreadPolicy& policy, const CpuPool pool, !defined(KOKKOS_ENABLE_HIP) if ((policy.explicit_pools & (1U << index)) != 0) return capacity; - std::size_t threshold = stage_thread_min_elements; - std::size_t full = stage_full_thread_elements; - switch (operation) { - case CpuOperation::stage: - break; - case CpuOperation::copy: - threshold = copy_thread_min_elements; - full = copy_thread_min_elements; - break; - case CpuOperation::response: - threshold = response_thread_min_elements; - full = response_full_thread_elements; - break; - case CpuOperation::modal: - threshold = modal_thread_min_elements; - full = modal_thread_min_elements; - break; - case CpuOperation::flow: - threshold = flow_thread_min_elements; - full = flow_thread_min_elements; - break; - case CpuOperation::dense: - threshold = dense_thread_min_elements; - full = dense_full_thread_elements; - break; - case CpuOperation::fft: - threshold = policy.threaded_fft_min_samples; - full = fft_full_thread_elements; - break; - case CpuOperation::raman: - threshold = raman_thread_min_elements; - full = raman_full_thread_elements; - break; - case CpuOperation::adams: - threshold = adams_thread_min_elements; - full = adams_full_thread_elements; - break; - } - if (const std::size_t divisor = threshold_divisor.load(std::memory_order_relaxed); - divisor > 1 && (operation != CpuOperation::fft || - threshold == default_threaded_fft_min_samples)) { - threshold = std::max(1, threshold / divisor); - full = std::max(threshold, full / divisor); - } - if (elements < threshold) + const OperationGrain& grain = execution[operation]; + if (elements < grain.threshold) return 1; if (operation == CpuOperation::response && - (fine_samples >= response_full_thread_samples || - (fine_samples != 0 && elements / fine_samples >= response_full_thread_columns))) + (fine_samples >= execution.response_full_samples || + (fine_samples != 0 && + elements / fine_samples >= execution.response_full_columns))) return capacity; - return elements < full ? std::min(capacity, cpu_medium_threads) : capacity; + return elements < grain.full ? std::min(capacity, execution.medium_threads) + : capacity; #else + static_cast(execution); static_cast(operation); static_cast(elements); static_cast(fine_samples); @@ -317,13 +283,6 @@ void configure_native_spin(const int milliseconds) { if (milliseconds < 0) throw std::invalid_argument("a spin time cannot be negative"); native_spin_milliseconds = milliseconds; -#if defined(KOKKOS_ENABLE_OPENMP) && defined(KMP_VERSION_MAJOR) && \ - defined(__APPLE__) && !defined(KOKKOS_ENABLE_CUDA) && !defined(KOKKOS_ENABLE_HIP) - threshold_divisor = - milliseconds > 0 && runtime_width.load() <= spinning_threshold_max_team - ? spinning_threshold_divisor - : 1; -#endif } int scoped_block_time() noexcept { @@ -348,18 +307,19 @@ bool compact_problem(const Problem& problem) noexcept { return problem.n_columns() == 1 && problem.n_nodes == 1 && problem.n_pol_transform == 1 && !problem.general_modal && !problem.coupled_flow && !problem.separable_geometry() && - problem.n_to < compact_max_fine_samples; + problem.n_to < problem.cpu_execution.compact_max_fine_samples; } const ExecutionSpace& CpuExecutionPool::get(const CpuOperation operation, const std::size_t elements, const std::size_t fine_samples) { const ThreadPolicy policy = thread_policy(); - int width = operation_threads(policy, pool_, operation, elements, fine_samples); + int width = + operation_threads(policy, execution_, pool_, operation, elements, fine_samples); int capacity = policy.kernels; if (compact_ && policy.explicit_pools == 0) { capacity = pool_ == CpuPool::kernels || fftw_team_loops() - ? std::min(capacity, cpu_compact_threads) + ? std::min(capacity, execution_.compact_threads) : 1; // On the compact team a threaded transform takes the whole team: its loops run on // that team anyway. The Raman transforms keep their own start: threading the @@ -375,8 +335,8 @@ const ExecutionSpace& CpuExecutionPool::get(const CpuOperation operation, // pool has a full-width kernel, a medium one takes the full width too. FFTW's // threaded loops keep the runtime's team and only distribute fewer iterations, so the // FFT pools keep their medium widths. Workspaces ask for their stage instance first. - // Not on macOS (`stable_kernel_team`). - if (stable_kernel_team && pool_ == CpuPool::kernels && width > 1 && + // Not on macOS (`CpuExecution::stable_kernel_team`). + if (execution_.stable_kernel_team && pool_ == CpuPool::kernels && width > 1 && width < capacity && instances_.contains(capacity)) width = capacity; auto found = instances_.find(width); diff --git a/native/src/general.cpp b/native/src/general.cpp index f0f2c73aa..619035b2d 100644 --- a/native/src/general.cpp +++ b/native/src/general.cpp @@ -599,16 +599,28 @@ inline void seed_fftw_wisdom(const ExecutionSpace& space, Complex* input, Complex* output, const std::vector& extents, const int howmany, const int input_distance, const int output_distance, const int sign) { - const unsigned effort = fftw_seed_effort(FftShape::planes, space.concurrency()); - if (effort == FFTW_ESTIMATE) - return; + std::string sizes; + for (const int extent : extents) + sizes += (sizes.empty() ? "" : "x") + std::to_string(extent); + const std::string key = fft_plan_key( + sign == FFTW_FORWARD ? "planes-forward" : "planes-backward", sizes, howmany, + input_distance, output_distance, input, output, space.concurrency()); fftw_init_threads(); + const auto chosen = begin_seed(key, FftShape::planes, space.concurrency()); + if (!chosen) + return; fftw_plan_with_nthreads(space.concurrency()); auto* in = reinterpret_cast(input); auto* out = reinterpret_cast(output); const fftw_plan seeded = fftw_plan_many_dft( static_cast(extents.size()), extents.data(), howmany, in, nullptr, 1, - input_distance, out, nullptr, 1, output_distance, sign, effort); + input_distance, out, nullptr, 1, output_distance, sign, *chosen); + // The policy has always tolerated a plane it cannot seed; a profile records it. + if (seeded == nullptr && (*chosen & FFTW_WISDOM_ONLY) == 0U && + (detail::planning == nullptr || + detail::planning->mode == FftPlanning::Mode::legacy)) + return; + end_seed(key, *chosen, seeded != nullptr); if (seeded != nullptr) fftw_destroy_plan(seeded); } @@ -2109,6 +2121,7 @@ class GeneralWorkspace { public: explicit GeneralWorkspace(Problem problem) : problem_(std::move(problem)) { require_general_problem(problem_); + require_cpu_execution(problem_.cpu_execution); identity_crop_ = problem_.crop_index.size() == problem_.n_omega; for (std::size_t index = 0; identity_crop_ && index < problem_.n_omega; ++index) identity_crop_ = problem_.crop_index[index] == index; @@ -2163,6 +2176,9 @@ class GeneralWorkspace { if (!problem_.spatial_absorber_matrix.empty()) absorber_execution_space_ = kernel_pool_.get( CpuOperation::dense, problem_.n_columns() * problem_.n_to * problem_.n_modes); + // solprop SEP 46: the workspace's own FFT planning, wisdom isolated from the rest + // of the process in a calibration's modes. + const FftPlanningScope planning(problem_.fft_planning); allocate(); upload(); const auto heap_before = heap_bytes(); @@ -2314,6 +2330,9 @@ class GeneralWorkspace { raman_pool_.widest(), raman_tail_pool_.widest()}) : 0; } + const CpuExecution& cpu_execution() const noexcept { + return kernel_pool_.execution(); + } CpuSizing execution_sizing() const { auto result = kernel_pool_.sizing(); result.merge(fft_pool_.sizing()); @@ -2672,10 +2691,10 @@ class GeneralWorkspace { // One real mode at several nodes in one chunk: synthesis and projection through // the one-mode kernels on the response team (gemm.hpp). bool one_mode_products_{}; - CpuExecutionPool kernel_pool_{CpuPool::kernels}; - CpuExecutionPool fft_pool_{CpuPool::fft}; - CpuExecutionPool raman_pool_{CpuPool::raman_fft}; - CpuExecutionPool raman_tail_pool_{CpuPool::raman_tail_fft}; + CpuExecutionPool kernel_pool_{CpuPool::kernels, problem_.cpu_execution}; + CpuExecutionPool fft_pool_{CpuPool::fft, problem_.cpu_execution}; + CpuExecutionPool raman_pool_{CpuPool::raman_fft, problem_.cpu_execution}; + CpuExecutionPool raman_tail_pool_{CpuPool::raman_tail_fft, problem_.cpu_execution}; mutable ExecutionSpace execution_space_; ExecutionSpace response_execution_space_, response_tail_execution_space_; ExecutionSpace transform_execution_space_, transform_tail_execution_space_; @@ -3463,6 +3482,10 @@ class GeneralBatchWorkspace { } if (one.problem_.n_columns() != columns_ || one.problem_.n_omega != omega_) throw std::invalid_argument("native batch member layouts differ"); + // The batch's updates run on one team for every member (solprop SEP 46). + if (one.problem_.cpu_execution != kernel_pool_.execution()) + throw std::invalid_argument( + "native batch members' CPU execution records differ"); } const auto count = checked_product(columns_, members_.size(), "batch columns"); // The batch's updates take its members' team. @@ -3551,8 +3574,18 @@ class GeneralBatchWorkspace { private: using Descriptors = Kokkos::View; + // The first member's record, which every member must share; read before the + // constructor's checks, so it refuses an empty or unloaded batch itself. + static const CpuExecution& + first_execution(const std::vector>& members) { + if (members.empty()) + throw std::invalid_argument("a native batch needs at least one member"); + if (!members.front() || members.front()->closed()) + throw std::invalid_argument("native batch members must be open"); + return members.front()->workspace_->problem_.cpu_execution; + } std::vector> members_; - CpuExecutionPool kernel_pool_{CpuPool::kernels}; + CpuExecutionPool kernel_pool_{CpuPool::kernels, first_execution(members_)}; ExecutionSpace execution_; std::size_t columns_{}, omega_{}; Descriptors descriptors_; @@ -17123,6 +17156,12 @@ CpuSizing GeneralState::execution_sizing() const { const CpuTeamScope team(workspace_->cpu_team()); return workspace_->execution_sizing(); } +const CpuExecution& GeneralState::cpu_execution() const { + return workspace_->cpu_execution(); +} +const FftPlanning& GeneralState::fft_planning() const { + return workspace_->problem_.fft_planning; +} double GeneralState::planning_seconds() const noexcept { return workspace_ ? workspace_->planning_seconds() : 0.0; } diff --git a/native/src/general_modal.cpp b/native/src/general_modal.cpp index b95439996..ab7ffafc6 100644 --- a/native/src/general_modal.cpp +++ b/native/src/general_modal.cpp @@ -262,8 +262,13 @@ GeneralModalSource::GeneralModalSource(const Problem& problem) map_terms_(problem.general_map_intervals * problem.general_map_n_deg), map_n_deg_(problem.general_map_n_deg), n_stages_(problem.n_stages), crop_offset_(problem.crop_index.empty() ? 0 : problem.crop_index.front()), - real_form_(problem.general_real_form), epsilon0_(problem.general_epsilon0) { + real_form_(problem.general_real_form), epsilon0_(problem.general_epsilon0), + kernel_pool_(CpuPool::kernels, problem.cpu_execution), + fft_pool_(CpuPool::fft, problem.cpu_execution), + raman_pool_(CpuPool::raman_fft, problem.cpu_execution), + raman_tail_pool_(CpuPool::raman_tail_fft, problem.cpu_execution) { require_general_modal(problem); + require_cpu_execution(problem.cpu_execution); if (!problem.general_modal) throw std::invalid_argument("the problem does not declare the general modal route"); kernels_ = kernel_pool_.get(CpuOperation::stage, n_modes_ * n_omega_); diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py index e0a1233cc..2f7a80e98 100644 --- a/native/src/solprop_native/__init__.py +++ b/native/src/solprop_native/__init__.py @@ -27,9 +27,19 @@ import numpy as np from ._bind import proc_bind -from ._budget import native_threads, physical_cores, thread_budget, top_tier_cores +from ._budget import ( + cgroup_cpus, + native_threads, + physical_cores, + thread_budget, + thread_count, + top_tier_cores, +) from ._cpu import check_cpu_baseline, select_variant -from ._wait import wait_policy +from ._execution import ExecutionRecord +from ._execution import builtin as _builtin_execution +from ._profile import Choice, Process, choose +from ._wait import requested_wait, wait_policy if platform.system() == "Darwin" and "pyfftw" in sys.modules: # solprop does not load pyFFTW; this is reached when a script, or a package it @@ -59,12 +69,42 @@ THREAD_BUDGET = thread_budget( _CPUS, physical_cores(PROCESS_CPUS or tuple(range(_CPUS))) ) -_REQUESTED_THREADS = native_threads(THREAD_BUDGET) +_BUDGET_THREADS = native_threads(THREAD_BUDGET) + +# A wheel with more than one build registers the chosen one as ``._core`` below. +_STEM = select_variant(Path(__file__).parent) + +# This machine's calibration (solprop SEP 46), when one was published for this build +# and allocation: an exact row's process settings apply here, before the runtime +# starts, unless the user set them; each workspace takes its workspace settings. +PROFILE: Choice = choose( + packages=[Path(entry) for entry in __path__], + stem=_STEM, + cpus=PROCESS_CPUS, + quota=cgroup_cpus(), +) +# A scan's worker is given SOLPROP_THREADS by solprop's Pool, its partition: there the +# row of the workers' allocation may narrow the pool within it. +_PROFILE_THREADS = PROFILE.threads() +_REQUESTED_THREADS = ( + min(_PROFILE_THREADS, THREAD_BUDGET.threads) + if _PROFILE_THREADS is not None + and ( + thread_count("SOLPROP_THREADS") is None + or os.environ.get("SOLPROP_CALIBRATION_ROLE", "").strip() == "worker" + ) + and thread_count("SOLPROP_NATIVE_THREADS") is None + else _BUDGET_THREADS +) # How the OpenMP runtime's idle threads wait: a short spin within native work, unless -# the user chose otherwise (ADR-071). The runtime reads this when the core loads it, -# just below. -_WAIT = wait_policy(threads=_REQUESTED_THREADS, top_tier=top_tier_cores()) +# the user or a calibration chose otherwise (ADR-071). The runtime reads this when the +# core loads it, just below. +_WAIT = wait_policy( + threads=_REQUESTED_THREADS, + top_tier=top_tier_cores(), + preferred=PROFILE.wait() or requested_wait(), +) WAIT_POLICY = _WAIT.description # Whether its threads are bound: not, unless the user placed them. Kokkos reads this @@ -74,7 +114,6 @@ # A wheel with more than one build registers the chosen one as ``._core``, so nothing # else knows which it is. The loader finds PyInit__core from the module's name, not the # file's. -_STEM = select_variant(Path(__file__).parent) if _STEM != "_core": _file = next(Path(__file__).parent.glob(f"{_STEM}.*")) _spec = importlib.util.spec_from_file_location(f"{__name__}._core", _file) @@ -157,11 +196,67 @@ def _band_request() -> int: _pool_request("SOLPROP_NATIVE_FFT_THREADS"), _pool_request("SOLPROP_NATIVE_RAMAN_FFT_THREADS"), _pool_request("SOLPROP_NATIVE_RAMAN_TAIL_FFT_THREADS"), - _pool_request("SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES", "sample count"), _band_request(), ) _core.configure_native_spin(_WAIT.spin_milliseconds) +# The built-in table's CPU execution record for this process (solprop SEP 46 section +# 6.11): its operating system's values, divided on Apple silicon's spinning top tier, +# with the user's FFT start. A workspace copies a record when it is built. +_FFT_START = ( + _pool_request("SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES", "sample count") or None +) +BUILTIN_EXECUTION: ExecutionRecord = _builtin_execution( + platform.system(), + spin_milliseconds=_WAIT.spin_milliseconds, + width=_REQUESTED_THREADS, + fft_threshold=_FFT_START, +) + +# The process settings in force, and the record a workspace takes by default: the +# profile's workspace settings where they hold under them, the user's FFT start over +# both. +PROCESS = Process(threads=_REQUESTED_THREADS, wait=WAIT_POLICY) +_PROFILED, EXECUTION_SOURCE = PROFILE.workspace(PROCESS, BUILTIN_EXECUTION) +EXECUTION: ExecutionRecord = ( + _PROFILED if _FFT_START is None else _PROFILED.with_fft_start(_FFT_START) +) +# The profile's FFT planning, or None for the plan policy (solprop SEP 46 section 6.7). +PLANNING: dict[str, object] | None = PROFILE.planning(PROCESS) + + +def refresh_profile() -> str: + """Read the store again, keeping the process settings in force (solprop SEP 46). + + A first run that calibrated after this package's import applies the new profile's + workspace settings and FFT planning that hold under the process's waiting policy + and width; its process settings take effect from the next process. Workspaces + built afterwards take the new record. + + Returns + ------- + str + Where a workspace's record now comes from (``EXECUTION_SOURCE``). + """ + # The profile's names are this process's state, rebound here and nowhere else; + # the backend reads them from the package at each use. + global PROFILE, EXECUTION, EXECUTION_SOURCE, PLANNING + found = choose( + packages=[Path(entry) for entry in __path__], + stem=_STEM, + cpus=PROCESS_CPUS, + quota=cgroup_cpus(), + ) + profiled, source = found.workspace(PROCESS, BUILTIN_EXECUTION) + PROFILE = found # pyright: ignore[reportConstantRedefinition] + EXECUTION_SOURCE = source # pyright: ignore[reportConstantRedefinition] + EXECUTION = ( # pyright: ignore[reportConstantRedefinition] + profiled if _FFT_START is None else profiled.with_fft_start(_FFT_START) + ) + PLANNING = found.planning(PROCESS) # pyright: ignore[reportConstantRedefinition] + return source + + # Fit a host OpenBLAS to the Kokkos pool unless its thread count is set explicitly. A # pthreads OpenBLAS runs its own pool beside Kokkos's and oversubscribes the cores (2 to # 75 times slower on the modal and wall cases), so it is held to one thread; an OpenMP @@ -562,6 +657,11 @@ class BuildInfo: threaded_fft_min_samples Real-side samples per execution from which a modal or spatial CPU FFT plan is threaded; shorter plans run on one FFTW thread. + + This field and the work sizes and medium width below are the built-in + table's for this process (``BUILTIN_EXECUTION``, solprop SEP 46), not + facts of the build: a workspace may apply a machine calibration's instead, + and a run's metadata records the values it applied (``calibration``). cpu_size_policy_enabled Whether this build supports operation-specific CPU widths (OpenMP CPU only). explicit_thread_pools @@ -756,26 +856,26 @@ def build_info() -> BuildInfo: fftw_threads=int(record["fftw_threads"]), raman_fft_threads=int(record["raman_fft_threads"]), raman_tail_fft_threads=int(record["raman_tail_fft_threads"]), - threaded_fft_min_samples=int(record["threaded_fft_min_samples"]), + threaded_fft_min_samples=BUILTIN_EXECUTION.fft_threshold, cpu_size_policy_enabled=bool(record["cpu_size_policy_enabled"]), explicit_thread_pools=int(record["explicit_thread_pools"]), - stage_thread_min_elements=int(record["stage_thread_min_elements"]), - copy_thread_min_elements=int(record["copy_thread_min_elements"]), - flow_thread_min_elements=int(record["flow_thread_min_elements"]), - modal_thread_min_elements=int(record["modal_thread_min_elements"]), - response_thread_min_elements=int(record["response_thread_min_elements"]), - dense_thread_min_elements=int(record["dense_thread_min_elements"]), - raman_thread_min_elements=int(record["raman_thread_min_elements"]), - stage_full_thread_elements=int(record["stage_full_thread_elements"]), - response_full_thread_elements=int(record["response_full_thread_elements"]), - response_full_thread_samples=int(record["response_full_thread_samples"]), - response_full_thread_columns=int(record["response_full_thread_columns"]), - dense_full_thread_elements=int(record["dense_full_thread_elements"]), - fft_full_thread_elements=int(record["fft_full_thread_elements"]), - raman_full_thread_elements=int(record["raman_full_thread_elements"]), - adams_thread_min_elements=int(record["adams_thread_min_elements"]), - adams_full_thread_elements=int(record["adams_full_thread_elements"]), - cpu_medium_threads=int(record["cpu_medium_threads"]), + stage_thread_min_elements=BUILTIN_EXECUTION.stage_threshold, + copy_thread_min_elements=BUILTIN_EXECUTION.copy_threshold, + flow_thread_min_elements=BUILTIN_EXECUTION.flow_threshold, + modal_thread_min_elements=BUILTIN_EXECUTION.modal_threshold, + response_thread_min_elements=BUILTIN_EXECUTION.response_threshold, + dense_thread_min_elements=BUILTIN_EXECUTION.dense_threshold, + raman_thread_min_elements=BUILTIN_EXECUTION.raman_threshold, + stage_full_thread_elements=BUILTIN_EXECUTION.stage_full, + response_full_thread_elements=BUILTIN_EXECUTION.response_full, + response_full_thread_samples=BUILTIN_EXECUTION.response_full_samples, + response_full_thread_columns=BUILTIN_EXECUTION.response_full_columns, + dense_full_thread_elements=BUILTIN_EXECUTION.dense_full, + fft_full_thread_elements=BUILTIN_EXECUTION.fft_full, + raman_full_thread_elements=BUILTIN_EXECUTION.raman_full, + adams_thread_min_elements=BUILTIN_EXECUTION.adams_threshold, + adams_full_thread_elements=BUILTIN_EXECUTION.adams_full, + cpu_medium_threads=BUILTIN_EXECUTION.medium_threads, band_column=str(record["band_column"]), band_tile_threads=int(record["band_tile_threads"]), native_arch_enabled=bool(record["native_arch_enabled"]), diff --git a/native/src/solprop_native/_allocation.py b/native/src/solprop_native/_allocation.py new file mode 100644 index 000000000..90cc5e22f --- /dev/null +++ b/native/src/solprop_native/_allocation.py @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Allocation classes: which part of a machine a process holds (solprop SEP 46). + +The same machine behaves differently in different allocations: eight cores sharing a +cache are not eight cores across two sockets. A calibration's rows are therefore +keyed by an :class:`AllocationClass` (SEP 46 section 6.4): the cores held and their +kinds, how many shared L2 groups, L3 groups, NUMA nodes and sockets they span, +whether SMT siblings are in use, and a CPU quota below the core count. A process +computes its own class from its affinity mask and quota, and takes the row that +:func:`match` chooses. + +This module is standard library only and loads nothing of the package. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Literal, cast + +from ._topology import Topology + +__all__ = ["AllocationClass", "Match", "classify", "match"] + + +@dataclass(frozen=True) +class AllocationClass: + """The shape of the CPUs a process holds. + + Attributes + ---------- + cores : int + Physical cores among its CPUs. + kinds : tuple of int + Of those, the cores of each kind, fastest first. + l2_groups : int + Shared L2 groups spanned, zero where each core has its own L2. + l3_groups : int + L3 groups spanned. + numa_nodes, sockets : int + NUMA nodes and packages spanned. + smt : bool + Whether two CPUs of one core are held. + quota : int or None + A CPU quota below `cores` (control groups, a job's limit), or ``None``. + """ + + cores: int + kinds: tuple[int, ...] + l2_groups: int + l3_groups: int + numa_nodes: int + sockets: int + smt: bool + quota: int | None = None + + def record(self) -> dict[str, object]: + """Return the class as JSON data.""" + return { + "cores": self.cores, + "kinds": list(self.kinds), + "l2_groups": self.l2_groups, + "l3_groups": self.l3_groups, + "numa_nodes": self.numa_nodes, + "sockets": self.sockets, + "smt": self.smt, + "quota": self.quota, + } + + @classmethod + def read(cls, data: Mapping[str, object]) -> AllocationClass: + """Read a class written by :meth:`record`. + + Raises + ------ + ValueError + When `data` is not a class. + """ + try: + kinds = data["kinds"] + quota = data.get("quota") + if not isinstance(kinds, list) or not all( + type(kind) is int for kind in cast(list[object], kinds) + ): + raise TypeError("kinds") + counts = { + name: data[name] + for name in ("cores", "l2_groups", "l3_groups", "numa_nodes", "sockets") + } + if not all(type(value) is int for value in counts.values()): + raise TypeError("counts") + if type(data["smt"]) is not bool or not ( + quota is None or type(quota) is int + ): + raise TypeError("smt or quota") + except (KeyError, TypeError) as error: + raise ValueError(f"not an allocation class: {error}") from error + return cls( + cores=counts["cores"], # pyright: ignore[reportArgumentType] - checked + kinds=tuple(kinds), # pyright: ignore[reportUnknownArgumentType] - checked + l2_groups=counts["l2_groups"], # pyright: ignore[reportArgumentType] + l3_groups=counts["l3_groups"], # pyright: ignore[reportArgumentType] + numa_nodes=counts["numa_nodes"], # pyright: ignore[reportArgumentType] + sockets=counts["sockets"], # pyright: ignore[reportArgumentType] + smt=bool(data["smt"]), + quota=quota, # pyright: ignore[reportArgumentType] - checked + ) + + def key(self) -> str: + """Return a short stable name for the class, for claims and attempts.""" + text = json.dumps(self.record(), sort_keys=True) + return hashlib.blake2b(text.encode(), digest_size=6).hexdigest() + + def span(self) -> tuple[object, ...]: + """Return the class's span structure: everything but its core count.""" + return ( + tuple(count > 0 for count in self.kinds), + self.l2_groups, + self.l3_groups, + self.numa_nodes, + self.sockets, + self.smt, + self.quota is not None, + ) + + +def classify( + topology: Topology, cpus: Sequence[int], quota: int | None = None +) -> AllocationClass: + """Return the class of a process holding `cpus` under `quota` CPUs. + + CPUs `topology` does not list are ignored; an empty mask holds the whole machine. + """ + held = [cpu for cpu in cpus if cpu in topology.core] or list(topology.cpus) + cores = {topology.core[cpu] for cpu in held} + # Every kind the machine has, held or not, so that allocations of the same size on + # cores of different kinds are different classes. + kinds: dict[int, set[int]] = { + topology.kind.get(cpu, 0): set() for cpu in topology.cpus + } + for cpu in held: + kinds.setdefault(topology.kind.get(cpu, 0), set()).add(topology.core[cpu]) + machine_l2 = _group_sizes(topology, topology.l2) + shared_l2 = any(size > 1 for size in machine_l2.values()) + smt = len(held) > len(cores) + return AllocationClass( + cores=len(cores), + kinds=tuple(len(kinds[kind]) for kind in sorted(kinds)), + l2_groups=len({topology.l2[cpu] for cpu in held}) if shared_l2 else 0, + l3_groups=len({topology.l3[cpu] for cpu in held}), + numa_nodes=len({topology.numa[cpu] for cpu in held}), + sockets=len({topology.socket[cpu] for cpu in held}), + smt=smt, + quota=quota if quota is not None and quota < len(cores) else None, + ) + + +def _group_sizes(topology: Topology, groups: Mapping[int, int]) -> dict[int, int]: + """Return each group's physical cores.""" + cores: dict[int, set[int]] = {} + for cpu in topology.cpus: + cores.setdefault(groups[cpu], set()).add(topology.core[cpu]) + return {group: len(members) for group, members in cores.items()} + + +@dataclass(frozen=True) +class Match[Row]: + """The row a process takes, and how much of it applies. + + Attributes + ---------- + row : Row + The chosen row. + exact : bool + Whether its class is the process's own. Only an exact match supplies process + settings; a nearer one supplies workspace settings only. + """ + + row: Row + exact: bool + + @property + def scope(self) -> Literal["all", "workspace"]: + """``"all"`` for an exact match, else ``"workspace"``.""" + return "all" if self.exact else "workspace" + + +def match[Row]( + allocation: AllocationClass, rows: Sequence[tuple[AllocationClass, Row]] +) -> Match[Row] | None: + """Choose the row for `allocation` among `rows`, by SEP 46 section 6.4. + + An exact match is used whole. Otherwise the row with the same span structure and + the nearest smaller core count supplies workspace settings, decided by the + maintainer (SEP 46 section 12 item 5). Otherwise ``None``: the built-in table. + Among equal classes the last row wins, so a later, higher level replaces an + earlier one. + """ + exact = [row for cls, row in rows if cls == allocation] + if exact: + return Match(exact[-1], exact=True) + smaller = [ + (cls.cores, index, row) + for index, (cls, row) in enumerate(rows) + if cls.span() == allocation.span() and cls.cores < allocation.cores + ] + if smaller: + return Match(max(smaller, key=lambda item: (item[0], item[1]))[2], exact=False) + return None diff --git a/native/src/solprop_native/_core.pyi b/native/src/solprop_native/_core.pyi index ece3993e6..f14b5d6b2 100644 --- a/native/src/solprop_native/_core.pyi +++ b/native/src/solprop_native/_core.pyi @@ -370,6 +370,10 @@ class State: @property def execution_sizing(self) -> dict[str, dict[str, int]]: ... @property + def cpu_execution(self) -> dict[str, int | bool]: ... + @property + def fft_planning(self) -> dict[str, object]: ... + @property def raman_kernel_transforms(self) -> int: ... @property def fsal_ready(self) -> bool: ... @@ -417,13 +421,13 @@ def initialize_runtime( fft: int = 0, raman_fft: int = 0, raman_tail_fft: int = 0, - threaded_fft_min_samples: int = 0, band_tiles: int = -1, ) -> None: ... def reference_fft3_pairs( values: npt.NDArray[np.complex128], warm: int, pairs: int ) -> list[float]: ... def reference_stream_copies(elements: int, warm: int, repeats: int) -> list[float]: ... +def forget_fftw_wisdom() -> None: ... def scoped_openmp_default() -> int: ... def configure_native_spin(milliseconds: int) -> None: ... def scoped_block_time() -> int: ... diff --git a/native/src/solprop_native/_execution.py b/native/src/solprop_native/_execution.py new file mode 100644 index 000000000..18ea37d33 --- /dev/null +++ b/native/src/solprop_native/_execution.py @@ -0,0 +1,368 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A workspace's CPU execution settings and the built-in table (solprop SEP 46). + +Every native workspace chooses the width of each CPU operation from an +:class:`ExecutionRecord` copied into it when it is built (SEP 46 section 5's *workspace +settings*). Native code holds no values for them: they come from this module's +built-in table, from a machine calibration's profile, or from both, and the record a +workspace applied is read back into a run's metadata. + +This module is standard library only and loads nothing of the package, so solprop's +calibration can read it by path without starting the native runtime. + +Work is counted per operation class: elements for stages and copies, active columns * +fine samples for responses, rows * inner * samples for dense products, active bins * +modes^3 for matrix functions, elements * history rows for the exponential Adams +trial's passes, and actual real-side batch samples for FFT and Raman plans (tails +included). Below an operation's threshold it runs on one thread, below its full size +on the medium width, and from it on its pool's capacity. solprop's +``docs/dev/benchmarks.md``, section "Kokkos CPU operation-size threading", records the +windows and sweeps behind each built-in value; ``docs/dev/cpu-performance.md`` gives +the table's provenance. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +__all__ = [ + "OPERATIONS", + "SPIN_DIVISOR", + "SPIN_MAX_TEAM", + "ExecutionRecord", + "builtin", +] + +OPERATIONS = ( + "stage", + "response", + "dense", + "fft", + "raman", + "copy", + "flow", + "modal", + "adams", +) +"""The CPU operation classes, in the native core's order (``CpuOperation``).""" + +SPIN_DIVISOR = 16 +"""What the built-in thresholds are divided by on Apple silicon's spinning top tier. + +The thresholds were swept with idle threads asleep, a fork and join of 16 threads +costing 100-200 us. On Apple silicon, while LLVM's runtime spins within native calls +(solprop ADR-071) on a team of at most :data:`SPIN_MAX_TEAM` threads, a region costs +about a microsecond. On an Apple M5 Max at six threads, on top of the spin, a sixteenth +ran the Raman GNLSE in 0.44 of its window, Raman hydrogen in 0.89, comb-to-continuum in +0.92 and the tracking cases in 0.87 on geometric mean, none slower; its top-tier cluster +shares one L2 cache. On two Intel Xeon Gold 6240, whose cores share only the L3, a +sixteenth ran the averaged argon and hydrogen 1.4-1.7 times slower at 4, 8, 18 and 36 +threads with either runtime, and a quarter lost on 16 cores of an AMD EPYC 7763 +(ADR-073). The grain belongs to the machine, which is what a calibration measures. +""" + +SPIN_MAX_TEAM = 8 +"""The widest team on which :data:`SPIN_DIVISOR` applies.""" + + +@dataclass(frozen=True) +class ExecutionRecord: + """One workspace's CPU execution settings, as the native core reads them. + + Attributes + ---------- + stage_threshold, response_threshold, dense_threshold, fft_threshold, + raman_threshold, copy_threshold, flow_threshold, modal_threshold, + adams_threshold : int + Each operation class's work below which it runs on one thread. The FFT + class's is where a modal or spatial FFTW plan starts to thread. + stage_full, response_full, dense_full, fft_full, raman_full, copy_full, flow_full, + modal_full, adams_full : int + Each class's work from which it takes its pool's capacity, at least its + threshold; between the two it takes ``medium_threads``. + response_full_samples, response_full_columns : int + A response of this many fine samples, or of this many columns, takes the + full pool at any size above its threshold. + medium_threads : int + The medium width. + compact_threads : int + The team of a compact problem (one column on one node with fewer than + ``compact_max_fine_samples`` fine samples): its kernels run on at most this + many threads and its threaded transforms on that whole team. + compact_max_fine_samples : int + The fine samples below which a one-column problem is compact. + stable_kernel_team : bool + Whether a medium-width kernel takes the full width once its pool holds a + full-width instance. + """ + + stage_threshold: int + stage_full: int + response_threshold: int + response_full: int + dense_threshold: int + dense_full: int + fft_threshold: int + fft_full: int + raman_threshold: int + raman_full: int + copy_threshold: int + copy_full: int + flow_threshold: int + flow_full: int + modal_threshold: int + modal_full: int + adams_threshold: int + adams_full: int + response_full_samples: int + response_full_columns: int + medium_threads: int + compact_threads: int + compact_max_fine_samples: int + stable_kernel_team: bool + + def __post_init__(self) -> None: + """Refuse a record the native core would refuse.""" + for name in OPERATIONS: + threshold = getattr(self, f"{name}_threshold") + full = getattr(self, f"{name}_full") + if not _count(threshold) or not _count(full) or full < threshold: + raise ValueError( + f"{name}: a threshold and full size must be positive integers, " + f"the full size at least the threshold; got {threshold!r} and " + f"{full!r}" + ) + for name in ( + "response_full_samples", + "response_full_columns", + "medium_threads", + "compact_threads", + "compact_max_fine_samples", + ): + if not _count(getattr(self, name)): + raise ValueError( + f"{name} must be a positive integer, got {getattr(self, name)!r}" + ) + # A record read from JSON may hold anything here. + if not isinstance(cast(object, self.stable_kernel_team), bool): + raise ValueError( + f"stable_kernel_team must be a bool, got {self.stable_kernel_team!r}" + ) + + def native(self) -> dict[str, int | bool]: + """Return the record as the native core's ``cpu_execution`` field reads it.""" + return dataclasses.asdict(self) + + @classmethod + def from_native(cls, values: Mapping[str, object]) -> ExecutionRecord: + """Read a record written by :meth:`native` or read back from a state. + + Raises + ------ + ValueError + When a field is missing, unknown or out of range. + """ + names = {field.name for field in dataclasses.fields(cls)} + if set(values) != names: + missing = sorted(names - set(values)) + unknown = sorted(set(values) - names) + raise ValueError( + f"a CPU execution record's fields differ: missing {missing}, " + f"unknown {unknown}" + ) + return cls(**values) # pyright: ignore[reportArgumentType] - checked above + + def updated(self, values: Mapping[str, object]) -> ExecutionRecord: + """Return the record with `values`' fields replaced, validated. + + A profile row holds only the settings it measured; the rest stay as they are. + + Raises + ------ + ValueError + When `values` names a field the record does not hold, or a value is out + of range. + """ + names = {field.name for field in dataclasses.fields(self)} + unknown = sorted(set(values) - names) + if unknown: + raise ValueError(f"unknown CPU execution settings {unknown}") + return dataclasses.replace(self, **values) # pyright: ignore[reportArgumentType] - checked above + + def with_fft_start(self, start: int) -> ExecutionRecord: + """Return the record with a user's FFT start, which no profile overrides. + + Its full size stays where it is, or moves up to the start. + """ + return dataclasses.replace( + self, fft_threshold=start, fft_full=max(start, self.fft_full) + ) + + def differences(self, other: ExecutionRecord) -> dict[str, tuple[object, object]]: + """Return each field whose value differs from `other`'s, as (this, other).""" + mine, theirs = self.native(), other.native() + return { + name: (value, theirs[name]) + for name, value in mine.items() + if value != theirs[name] + } + + +def _count(value: object) -> bool: + """Whether `value` is a positive integer and not a bool.""" + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def builtin( + system: str, + *, + spin_milliseconds: int = 0, + width: int = 1, + fft_threshold: int | None = None, +) -> ExecutionRecord: + """Return the built-in table's record for a process. + + The table is keyed by operating system (`system`, as :func:`platform.system` + names it): macOS takes its own stage and FFT starts and no stable kernel team; + every other system takes the x86 Linux values, which were not measured on Windows + or Linux aarch64 (solprop issue #328). On macOS a process whose LLVM runtime spins + within native calls (`spin_milliseconds` > 0) on a team of at most + :data:`SPIN_MAX_TEAM` threads (`width`) divides every threshold and full size by + :data:`SPIN_DIVISOR` (solprop ADR-073). `fft_threshold`, from + ``SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES``, replaces the FFT start and is never + divided. + + Parameters + ---------- + system : str + ``"Darwin"``, ``"Linux"``, ``"Windows"`` or another :func:`platform.system` + name. + spin_milliseconds : int + LLVM's block time inside native calls, zero when the runtime does not spin + there. + width : int + The runtime's thread count. + fft_threshold : int or None + The user's FFT start, or ``None``. + + Returns + ------- + ExecutionRecord + The record a workspace of this process takes without a profile. + + Examples + -------- + >>> builtin("Linux").stage_threshold + 4096 + >>> builtin("Darwin", spin_milliseconds=1, width=6).stage_threshold + 512 + """ + apple = system == "Darwin" + # The stage boundary is platform-dependent. At 8192, just under the calibrated + # 8196-element RDW stage, the same case on a 2-3-5-smooth grid (n_t 4000, 8004 + # elements) ran its stage entry serially on Linux: 708 us against 59 us at full + # width on an 18-core Cascade Lake socket, 1.8 times the whole evaluation of n_t + # 4096. At 4096 (with the FFT start at 16384) n_t 4000 costs no more than 4096 + # there and on Zen 3; at 1024 a 1351-element SSFS stage regressed. On an Apple + # arm64 laptop the serial stage is the faster one at 8004 elements (and 4096's + # threaded one is already dearer than 4000's serial), so macOS keeps 8192. An + # 8196-element RDW stage needs the full pool in the server calibration; putting its + # reductions on a separate width loses that gain, so the stage's full size is its + # threshold. + stage = 8192 if apple else 4096 + # The FFT start. On Linux at 32768 the four-mode RDW's 32000-sample coarse batch + # (n_t 4000) ran serially, its transforms 2.5-3.2 times slower on an 18-core + # Cascade Lake socket than the 32768-sample batch's four-thread ones; 16384 also + # gains on Zen 3. On an Apple arm64 laptop the serial transforms are the faster + # ones at that size, so macOS keeps 32768. + fft = 32768 if apple else 16384 + record = ExecutionRecord( + stage_threshold=stage, + stage_full=stage, + # Responses keep four threads for many short columns (33 x 4096), while long + # 65536-sample columns take the full pool without threading small copy + # kernels. Wider chunks also need the full pool: switching between a + # four-thread response and a full-width FFT regressed the Cartesian filament on + # the server. The column boundary keeps the measured 33-column regime and + # includes 44-column tails. + response_threshold=32768, + response_full=196608, + # Native products stay serial below the conservative one-million-work + # crossover: 2 x 2 x 8192 favours serial on both hosts, whereas 33 x 4 x 8192 + # favours the full pool. Intermediate widths did not establish a + # complete-window benefit. + dense_threshold=1048576, + dense_full=1048576, + fft_threshold=fft, + fft_full=131072, + raman_threshold=65536, + raman_full=262144, + # An 8192-element temporal copy must stay serial: threading it changed + # averaged helium from 0.74 to 1.27 s in the server calibration. + copy_threshold=65536, + copy_full=65536, + # The 38691-work scalar-wall flow needs full width: its serial block functions + # took 2.39 s against 0.13 s with the full pool in the server window profile. + flow_threshold=32768, + flow_full=32768, + # General-modal synthesis and projection have dedicated team kernels, with + # work active nodes * components * spectral samples * modes. Their measured + # full-width threshold stays separate: serialising them regressed complete + # windows on both hosts, despite the generic small-GEMM crossover above. + modal_threshold=65536, + modal_full=65536, + # The exponential Adams trial's two passes cost about ten stage entries an + # element, so they thread on fields the stage keeps serial. Swept on + # single-column capillaries of 401 to 10241 elements with the passes forced to + # 1-16 threads of a 16-thread process, idle threads sleeping: on 16 cores of a + # dual EPYC 7643 four threads lost at 4487 (0.8x) and gained 1.1-2.1x from 5607 + # to 71687, while 16 lost to four on helium up to 17927 and to one at 8967; an + # 18-core Apple M5 Max gained at four from 4487 and at 16 over four only at + # 71687. + adams_threshold=8192, + adams_full=65536, + response_full_samples=65536, + response_full_columns=40, + medium_threads=4, + # A compact problem's stage of a few thousand to 16384 bins otherwise woke the + # whole pool for one short pass between serial transforms: on 16 cores of an + # Intel Xeon Gold 6240 the main thread then ran at about 2.7 instead of 3.5 + # GHz, its arrays came back from other cores' caches, and gnlse_raman_shock + # took as long on 16 threads as on one (docs/dev/benchmarks.md, "Compact CPU + # teams"). + compact_threads=8, + compact_max_fine_samples=65536, + # GNU OpenMP ends surplus threads when a team narrows, and on Windows, with + # LLVM's libomp, the wider team still ran h2_raman_averaged 1.1 times faster at + # eight threads. Not on macOS: on an Apple M5 Max, whose 18 cores are of two + # kinds, the promoted passes took argon_blueshift from 3.0 to 3.2 s at 18 + # threads. + stable_kernel_team=not apple, + ) + if apple and spin_milliseconds > 0 and width <= SPIN_MAX_TEAM: + record = _divided(record, SPIN_DIVISOR) + if fft_threshold is not None: + record = dataclasses.replace( + record, + fft_threshold=fft_threshold, + fft_full=max(fft_threshold, builtin(system).fft_full), + ) + return record + + +def _divided(record: ExecutionRecord, divisor: int) -> ExecutionRecord: + """Divide each threshold and full size; each full stays at least its threshold.""" + values: dict[str, int] = {} + for name in OPERATIONS: + threshold = max(1, getattr(record, f"{name}_threshold") // divisor) + values[f"{name}_threshold"] = threshold + values[f"{name}_full"] = max( + threshold, getattr(record, f"{name}_full") // divisor + ) + return dataclasses.replace(record, **values) diff --git a/native/src/solprop_native/_fingerprint.py b/native/src/solprop_native/_fingerprint.py new file mode 100644 index 000000000..4bed724eb --- /dev/null +++ b/native/src/solprop_native/_fingerprint.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""What makes a calibration's measurements portable (solprop SEP 46 section 5). + +A fingerprint digests the machine (its processor model, its cores of each kind, its +cache, NUMA and socket topology and SMT), the operating system, the native build and +the calibration's own version. The build is read from ``_build.json``, which the +build writes beside the extension, and from a digest of the chosen extension file, so +that nothing loads the extension (and its OpenMP runtime) to compute it. A new +release, a rebuilt extension, a changed calibration or a different machine finds no +matching profile and runs on the built-in table: nothing stale is ever applied. + +This module is standard library only and loads nothing of the package. +""" + +from __future__ import annotations + +import hashlib +import json +import platform +from collections.abc import Sequence +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +from ._topology import Topology + +__all__ = ["CALIBRATION_VERSION", "Fingerprint", "build_identity", "fingerprint"] + +CALIBRATION_VERSION = 1 +"""The calibration's own version, raised when its probes, problems or rules change.""" + + +@dataclass(frozen=True) +class Fingerprint: + """A machine and build's identity, and its store key. + + Attributes + ---------- + digest : str + The store's directory name for this machine and build: 24 hexadecimal digits. + machine : dict + The topology's record, the operating system and the architecture. + build : dict + The build record, the extension's variant, digest and version. + calibration : int + :data:`CALIBRATION_VERSION`. + """ + + digest: str + machine: dict[str, object] + build: dict[str, object] + calibration: int + + def record(self) -> dict[str, object]: + """Return the fingerprint as JSON data.""" + return { + "digest": self.digest, + "machine": self.machine, + "build": self.build, + "calibration": self.calibration, + } + + +def build_identity(packages: Sequence[Path], stem: str = "_core") -> dict[str, object]: + """Return the native build's identity without loading it. + + `packages` are the ``solprop_native`` package's directories (an editable install + keeps its sources and its built files apart) and `stem` the chosen extension's + (``_cpu.select_variant``). The extension file is digested whole: two installs of + one wheel agree, any rebuild differs. + """ + record: object = None + for directory in packages: + try: + record = json.loads((directory / "_build.json").read_text(encoding="utf-8")) + break + except OSError, ValueError: + continue + files = sorted( + path + for directory in packages + for path in directory.glob(f"{stem}.*") + if path.suffix in {".so", ".pyd", ".dylib"} + ) + digest = None + if files: + hasher = hashlib.blake2b(digest_size=16) + with files[0].open("rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + hasher.update(block) + digest = hasher.hexdigest() + try: + release = version("solprop-native") + except PackageNotFoundError: + release = None + return { + "record": record, + "variant": stem, + "extension": digest, + "version": release, + } + + +def fingerprint( + topology: Topology, + build: dict[str, object], + system: str | None = None, + machine: str | None = None, +) -> Fingerprint: + """Return the fingerprint of `topology` running `build` on this system.""" + identity = topology.record() | { + "system": system or platform.system(), + "architecture": machine or platform.machine(), + } + text = json.dumps( + {"machine": identity, "build": build, "calibration": CALIBRATION_VERSION}, + sort_keys=True, + ) + return Fingerprint( + digest=hashlib.blake2b(text.encode(), digest_size=12).hexdigest(), + machine=identity, + build=build, + calibration=CALIBRATION_VERSION, + ) diff --git a/native/src/solprop_native/_profile.py b/native/src/solprop_native/_profile.py new file mode 100644 index 000000000..7c8085c12 --- /dev/null +++ b/native/src/solprop_native/_profile.py @@ -0,0 +1,348 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Which calibration a process applies, and what of it (solprop SEP 46 section 6.4). + +At import the native package finds this machine's profile (:func:`choose`): the +machine and build's fingerprint, the published generation in the user's store or a +read-only directory, and the row for the process's allocation class. An exact row +supplies *process settings* (the pool width and how idle threads wait), which the +import applies before the runtime starts, unless the user set them. Each workspace +then takes the row's *workspace settings* over the built-in table +(:meth:`Choice.workspace`), but only where they were validated under the process +settings actually in force: a row fitted under spinning threads gives way to the +table in a process whose threads sleep. + +This module is standard library only and loads nothing of the package's extension. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from ._allocation import AllocationClass, Match, classify, match +from ._execution import ExecutionRecord +from ._fingerprint import Fingerprint, build_identity, fingerprint +from ._store import Generation, Mode, Row, Store, StoreWarning, lookup, mode, stores +from ._topology import Topology, read_topology + +__all__ = ["Choice", "Process", "choose", "descriptor"] + +WAITS = ("spin", "passive") +"""The waiting policies a row may choose (``_wait``'s settings).""" + + +@dataclass(frozen=True) +class Process: + """The process settings in force once the runtime started. + + Attributes + ---------- + threads : int + The runtime's width. + wait : str + ``"spin"`` or ``"passive"`` as the import chose, or ``"environment: ..."`` + for the user's choice (``_wait.WaitPolicy.description``). + """ + + threads: int + wait: str + + def record(self) -> dict[str, object]: + """Return the settings as JSON data.""" + return {"threads": self.threads, "wait": self.wait} + + +@dataclass(frozen=True) +class Choice: + """The profile a process found, and the row for its allocation. + + Attributes + ---------- + mode : str + ``SOLPROP_CALIBRATION``: ``"auto"``, ``"use"`` or ``"off"``. + fingerprint : Fingerprint or None + The machine and build's; ``None`` under ``off``. + allocation : AllocationClass or None + The process's class; ``None`` under ``off``. + generation : Generation or None + The published generation found, or ``None``. + found : Match or None + Its row for the allocation, and whether the match is exact. + note : str + Why no row applies, when none does. + """ + + mode: Mode + fingerprint: Fingerprint | None = None + allocation: AllocationClass | None = None + generation: Generation | None = None + found: Match[Row] | None = None + note: str = "" + + @property + def row(self) -> Row | None: + """The row the process takes, or ``None``.""" + return None if self.found is None else self.found.row + + def threads(self) -> int | None: + """Return the row's pool width, or ``None``; only an exact row supplies it.""" + if self.found is None or not self.found.exact: + return None + value = self.found.row.process.get("threads") + return value if type(value) is int and value > 0 else None + + def wait(self) -> str | None: + """Return the row's waiting policy, or ``None``; only an exact row has one.""" + if self.found is None or not self.found.exact: + return None + value = self.found.row.process.get("wait") + return value if isinstance(value, str) and value in WAITS else None + + def workspace( + self, process: Process, builtin: ExecutionRecord + ) -> tuple[ExecutionRecord, str]: + """Return a workspace's record under `process`, and why. + + The row's workspace settings over `builtin`, if they were validated under the + waiting policy in force and, for an exact row, the width in force too; + otherwise `builtin`. + """ + row = self.row + if row is None: + return builtin, self.note or "no profile" + if not row.workspace: + return builtin, "the row holds no workspace settings" + fitted = row.fitted_under + if fitted.get("wait") != process.wait: + return builtin, ( + f"fitted under {fitted.get('wait')!r} waiting, the process runs " + f"{process.wait!r}" + ) + assert self.found is not None + if self.found.exact and fitted.get("threads") not in (None, process.threads): + return builtin, ( + f"fitted on {fitted.get('threads')} threads, the process runs " + f"{process.threads}" + ) + try: + return builtin.updated(row.workspace), "profile" + except ValueError as error: + _warn(f"ignoring a calibration row's workspace settings: {error}") + return builtin, f"the row's workspace settings are invalid: {error}" + + def planning( + self, process: Process, environ: Mapping[str, str] = os.environ + ) -> dict[str, object] | None: + """Return a workspace's FFT planning under `process`, or ``None``. + + The row's plan efforts and the generation's wisdom (SEP 46 section 6.7), where + the row's workspace settings hold (:meth:`workspace`); ``None`` keeps the plan + policy: no profile, a row without planning, other process settings, or the + user's own ``SOLPROP_FFTW_PLAN`` or ``SOLPROP_FFTW_WISDOM``, which take + precedence. + """ + row = self.row + if row is None or self.generation is None or not row.planning: + return None + if any( + environ.get(name, "").strip() + for name in ("SOLPROP_FFTW_PLAN", "SOLPROP_FFTW_WISDOM") + ): + return None + fitted = row.fitted_under + assert self.found is not None + if fitted.get("wait") != process.wait or ( + self.found.exact and fitted.get("threads") not in (None, process.threads) + ): + return None + efforts = row.planning.get("efforts", {}) + if not isinstance(efforts, dict): + return None + return { + "mode": "profile", + "efforts": dict(cast(dict[str, str], efforts)), + "wisdom": dict(self.generation.wisdom), + } + + def record( + self, + process: Process, + applied: ExecutionRecord, + builtin: ExecutionRecord, + reason: str, + ) -> dict[str, object]: + """Return the ``calibration`` metadata of a run that applied `applied`.""" + settings = { + name: {"applied": value, "built_in": built} + for (name, value), built in zip( + applied.native().items(), builtin.native().values(), strict=True + ) + } + base: dict[str, object] = { + "mode": self.mode, + "process": process.record(), + "workspace": settings, + "workspace_source": reason, + } + if self.generation is None or self.found is None: + return base | {"profile": "built-in", "note": self.note or "no profile"} + row = self.found.row + return base | { + "profile": { + "level": row.level, + "fingerprint": self.generation.fingerprint, + "generation": self.generation.name, + "created": self.generation.created, + "allocation": dict(row.allocation), + "exact": self.found.exact, + "source": str(self.generation.source) + if self.generation.source is not None + else None, + }, + } + + +def descriptor( + *, + shape: Sequence[int], + grid_kind: str, + integrator: str, + sizing: Mapping[str, object], + process: Process, + fingerprint: str, +) -> str: + """Return a simulation's execution descriptor (SEP 46 section 6.6). + + A stage-2 refinement is reused only where the execution is the same: the state's + shape, the field or envelope grid, the integrator, every operation its workspace + runs with its work (``execution_sizing``'s keys: the dimensions, the node chunk, + the response buffers and the transforms' sizes, which set its plans), the process + settings and the machine and build. Equal descriptors reuse; anything else falls + back to the machine profile. + + Parameters + ---------- + shape : sequence of int + The state's shape. + grid_kind : str + ``"field"`` or ``"envelope"``. + integrator : str + The integrator's name. + sizing : Mapping[str, object] + The workspace's ``execution_sizing``; only its keys are read. + process : Process + The process settings. + fingerprint : str + The machine and build's store key. + + Returns + ------- + str + 24 hexadecimal digits. + """ + text = json.dumps( + { + "shape": list(shape), + "grid_kind": grid_kind, + "integrator": integrator, + "operations": sorted(sizing), + "process": process.record(), + "fingerprint": fingerprint, + }, + sort_keys=True, + ) + return hashlib.blake2b(text.encode(), digest_size=12).hexdigest() + + +def choose( + environ: Mapping[str, str] = os.environ, + *, + packages: Sequence[Path], + stem: str = "_core", + cpus: Sequence[int] = (), + quota: int | None = None, + topology: Topology | None = None, + searched: Sequence[Store] | None = None, +) -> Choice: + """Find this process's profile and row (SEP 46 sections 5 and 6.4). + + Under ``SOLPROP_CALIBRATION=off`` nothing is read. An unreadable store or a + malformed row leaves the built-in table, with one :class:`StoreWarning`. + + Parameters + ---------- + environ : Mapping[str, str] + The environment. + packages : Sequence[Path] + The ``solprop_native`` package's directories, for the build's identity. + stem : str + The chosen extension's stem. + cpus : Sequence[int] + The process's affinity mask; empty for the whole machine. + quota : int or None + Its CPU quota. + topology : Topology or None + The machine's, read when ``None``. + searched : Sequence[Store] or None + The stores to search, the user's and ``SOLPROP_CALIBRATION_PATH``'s when + ``None``. + + Returns + ------- + Choice + What was found. + """ + chosen = mode(environ) + if chosen == "off": + return Choice(mode=chosen, note="SOLPROP_CALIBRATION=off") + try: + machine = topology or read_topology() + allocation = classify(machine, cpus, quota) + identity = fingerprint(machine, build_identity(packages, stem)) + generation = lookup( + identity.digest, stores(environ) if searched is None else searched + ) + except OSError as error: + _warn(f"cannot read the calibration store: {error}") + return Choice(mode=chosen, note=f"the store is unreadable: {error}") + if generation is None: + return Choice( + mode=chosen, + fingerprint=identity, + allocation=allocation, + note="no profile for this machine and build", + ) + rows: list[tuple[AllocationClass, Row]] = [] + for row in generation.rows: + try: + rows.append((AllocationClass.read(row.allocation), row)) + except ValueError as error: + _warn(f"ignoring a calibration row: {error}") + found = match(allocation, rows) + return Choice( + mode=chosen, + fingerprint=identity, + allocation=allocation, + generation=generation, + found=found, + note="" if found is not None else "no row for this allocation", + ) + + +_WARNED: set[str] = set() + + +def _warn(message: str) -> None: + """Issue `message` once per process as a :class:`StoreWarning`.""" + if message not in _WARNED: + _WARNED.add(message) + warnings.warn(message, StoreWarning, stacklevel=3) diff --git a/native/src/solprop_native/_store.py b/native/src/solprop_native/_store.py new file mode 100644 index 000000000..190d4a9ab --- /dev/null +++ b/native/src/solprop_native/_store.py @@ -0,0 +1,614 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The per-user calibration store (solprop SEP 46 sections 5 and 6.8). + +One directory per user, the platform's user cache directory unless +``SOLPROP_CACHE_DIR`` moves it:: + + / + machines// + current # names the published generation + .json # profile rows, their evidence and their FFT wisdom + claim- # held while one process calibrates that class + attempts.json # inconclusive attempts, so that auto waits a day + problems//.json # stage-2 refinements + +A calibration writes one complete generation file, then atomically replaces +``current`` to name it, so a reader that reads ``current`` once and then that +generation never sees a partial profile, and a failed or interrupted upgrade leaves +the previous generation current. ``SOLPROP_CALIBRATION_PATH`` lists read-only +directories of the same layout searched after the store, for example a cluster's +profiles for each node type. + +This module is standard library only and loads nothing of the package, so solprop's +calibration controller can read it by path without starting the native runtime. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import platform +import secrets +import socket +import sys +import time +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal, cast + +__all__ = [ + "FORMAT", + "MODES", + "STALE_CLAIM_SECONDS", + "Claim", + "Generation", + "Mode", + "Row", + "Store", + "StoreWarning", + "cache_directory", + "generation_name", + "lookup", + "mode", + "refinement", + "search_path", + "stores", +] + +FORMAT = 1 +"""The generation file's format; a file of another format is not read.""" + +type Mode = Literal["auto", "use", "off"] +MODES: tuple[Mode, ...] = ("auto", "use", "off") +"""``SOLPROP_CALIBRATION``'s values: calibrate on first use, use only, or neither.""" + +STALE_CLAIM_SECONDS = 60.0 +"""How long past its deadline a claim is held before another process removes it.""" + +type Level = Literal["quick", "standard", "thorough"] +LEVELS: tuple[Level, ...] = ("quick", "standard", "thorough") + + +class StoreWarning(UserWarning): + """A store that cannot be read or written; runs keep the built-in table.""" + + +def mode(environ: Mapping[str, str] = os.environ) -> Mode: + """Return ``SOLPROP_CALIBRATION``, ``"auto"`` when unset. + + Raises + ------ + ValueError + For a value other than ``auto``, ``use`` or ``off``. + """ + value = environ.get("SOLPROP_CALIBRATION", "").strip().lower() or "auto" + for known in MODES: + if value == known: + return known + raise ValueError( + f"SOLPROP_CALIBRATION must be one of {', '.join(MODES)}, got {value!r}" + ) + + +def cache_directory( + environ: Mapping[str, str] = os.environ, system: str | None = None +) -> Path: + """Return the store's directory: the platform's user cache directory. + + ``SOLPROP_CACHE_DIR`` when set; otherwise ``$XDG_CACHE_HOME/solprop`` or + ``~/.cache/solprop`` on Linux and other POSIX systems, + ``~/Library/Caches/solprop`` on macOS and ``%LOCALAPPDATA%\\solprop\\Cache`` on + Windows. + """ + explicit = environ.get("SOLPROP_CACHE_DIR", "").strip() + if explicit: + return Path(explicit) + system = system or platform.system() + home = Path(environ.get("HOME") or Path.home()) + if system == "Darwin": + return home / "Library" / "Caches" / "solprop" + if system == "Windows": + local = environ.get("LOCALAPPDATA", "").strip() + base = Path(local) if local else home / "AppData" / "Local" + return base / "solprop" / "Cache" + xdg = environ.get("XDG_CACHE_HOME", "").strip() + return (Path(xdg) if xdg else home / ".cache") / "solprop" + + +def search_path(environ: Mapping[str, str] = os.environ) -> tuple[Path, ...]: + """Return ``SOLPROP_CALIBRATION_PATH``'s read-only directories, in order.""" + value = environ.get("SOLPROP_CALIBRATION_PATH", "") + return tuple(Path(part) for part in value.split(os.pathsep) if part.strip()) + + +@dataclass(frozen=True) +class Row: + """One allocation class's measured settings. + + Attributes + ---------- + allocation : Mapping[str, object] + The allocation class it was measured in (``_allocation.AllocationClass``'s + record). + level : str + The calibration level that measured it. + process : Mapping[str, object] + Its process settings: ``threads`` and ``wait`` (``"spin"`` or + ``"passive"``), and any other the level chose. + workspace : Mapping[str, object] + Its workspace settings, a partial ``_execution.ExecutionRecord``. + fitted_under : Mapping[str, object] + The process settings the workspace settings were validated under; they apply + only to a process running on the same. + evidence : Mapping[str, object] + What was measured: ratios, noise and why each setting was kept. + planning : Mapping[str, object] + Its FFT planning: ``efforts``, each plan group's effort; the plans' wisdom is + the generation's. + """ + + allocation: Mapping[str, object] + level: str + process: Mapping[str, object] = field(default_factory=dict[str, object]) + workspace: Mapping[str, object] = field(default_factory=dict[str, object]) + fitted_under: Mapping[str, object] = field(default_factory=dict[str, object]) + evidence: Mapping[str, object] = field(default_factory=dict[str, object]) + planning: Mapping[str, object] = field(default_factory=dict[str, object]) + + def record(self) -> dict[str, object]: + """Return the row as JSON data.""" + return { + "allocation": dict(self.allocation), + "level": self.level, + "process": dict(self.process), + "workspace": dict(self.workspace), + "fitted_under": dict(self.fitted_under), + "evidence": dict(self.evidence), + "planning": dict(self.planning), + } + + @classmethod + def read(cls, data: object) -> Row: + """Read a row written by :meth:`record`. + + Raises + ------ + ValueError + When `data` is not a row. + """ + values = _mapping(data, "row") + level = values.get("level") + if not isinstance(level, str) or level not in LEVELS: + raise ValueError(f"a row's level must be one of {LEVELS}, got {level!r}") + return cls( + allocation=_mapping(values.get("allocation"), "allocation"), + level=level, + process=_mapping(values.get("process", {}), "process"), + workspace=_mapping(values.get("workspace", {}), "workspace"), + fitted_under=_mapping(values.get("fitted_under", {}), "fitted_under"), + evidence=_mapping(values.get("evidence", {}), "evidence"), + planning=_mapping(values.get("planning", {}), "planning"), + ) + + +@dataclass(frozen=True) +class Generation: + """One published calibration: its rows, evidence and FFT wisdom together. + + Attributes + ---------- + fingerprint : str + The machine and build it measured (``_fingerprint``). + name : str + Its generation name, unique within the fingerprint. + created : str + When it was published, ISO 8601 in UTC. + rows : tuple of Row + One per allocation class measured. + wisdom : Mapping[str, str] + Each FFT shape class's winning wisdom, by plan key. + source : Path or None + The file it was read from; ``None`` before publication. + """ + + fingerprint: str + name: str + created: str + rows: tuple[Row, ...] + wisdom: Mapping[str, str] = field(default_factory=dict[str, str]) + source: Path | None = None + + def record(self) -> dict[str, object]: + """Return the generation as JSON data.""" + return { + "format": FORMAT, + "fingerprint": self.fingerprint, + "name": self.name, + "created": self.created, + "rows": [row.record() for row in self.rows], + "wisdom": dict(self.wisdom), + } + + @classmethod + def read(cls, data: object, source: Path | None = None) -> Generation: + """Read a generation written by :meth:`record`. + + Raises + ------ + ValueError + When `data` is not a generation of this format. + """ + values = _mapping(data, "generation") + if values.get("format") != FORMAT: + raise ValueError( + f"generation format {values.get('format')!r}, expected {FORMAT}" + ) + fingerprint, name, created = ( + values.get("fingerprint"), + values.get("name"), + values.get("created"), + ) + if not ( + isinstance(fingerprint, str) + and isinstance(name, str) + and isinstance(created, str) + ): + raise ValueError("a generation names its fingerprint, name and date") + rows = values.get("rows") + if not isinstance(rows, list): + raise ValueError("a generation's rows must be a list") + wisdom = _mapping(values.get("wisdom", {}), "wisdom") + if not all(isinstance(text, str) for text in wisdom.values()): + raise ValueError("a generation's wisdom must be text") + return cls( + fingerprint=fingerprint, + name=name, + created=created, + rows=tuple(Row.read(row) for row in cast(list[object], rows)), + wisdom=cast(Mapping[str, str], wisdom), + source=source, + ) + + +def _mapping(data: object, what: str) -> dict[str, object]: + """Return `data` as a string-keyed mapping, or raise naming `what`.""" + if not isinstance(data, dict) or not all( + isinstance(key, str) for key in cast(dict[object, object], data) + ): + raise ValueError(f"a {what} must be a mapping with string keys") + return dict(cast(dict[str, object], data)) + + +@dataclass(frozen=True) +class Claim: + """A held calibration claim on one allocation class; release it when done. + + Attributes + ---------- + path : Path + The claim file, created exclusively. + host, pid : str, int + Who holds it. + deadline : float + When its calibration must end, in seconds since the epoch. + """ + + path: Path + host: str + pid: int + deadline: float + + def release(self) -> None: + """Remove the claim, if it is still this one's.""" + with contextlib.suppress(OSError, ValueError): + held = json.loads(self.path.read_text(encoding="utf-8")) + if held.get("host") == self.host and held.get("pid") == self.pid: + self.path.unlink() + + +class Store: + """A calibration store rooted at `root`; read-only unless writes are asked for. + + Parameters + ---------- + root : Path + The store's directory (:func:`cache_directory`, or a read-only one). + """ + + def __init__(self, root: Path): + self.root = root + + def directory(self, fingerprint: str) -> Path: + """Return one fingerprint's directory.""" + return self.root / "machines" / fingerprint + + def current(self, fingerprint: str) -> Generation | None: + """Return the published generation, or ``None`` when there is none. + + A corrupt, mismatched or unreadable file is skipped with one + :class:`StoreWarning`. + """ + directory = self.directory(fingerprint) + try: + name = (directory / "current").read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + except OSError as error: + _warn(f"cannot read the calibration store {directory}: {error}") + return None + path = directory / f"{name}.json" + try: + generation = Generation.read( + json.loads(path.read_text(encoding="utf-8")), source=path + ) + except (OSError, ValueError) as error: + _warn(f"ignoring the calibration profile {path}: {error}") + return None + if generation.fingerprint != fingerprint or generation.name != name: + _warn(f"ignoring the calibration profile {path}: it names another machine") + return None + return generation + + def publish(self, generation: Generation) -> Path: + """Write `generation` completely, then make it current; return its file. + + Raises + ------ + OSError + When the store cannot be written; the previous generation stays current. + """ + directory = self.directory(generation.fingerprint) + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{generation.name}.json" + _write_atomically(path, json.dumps(generation.record(), indent=1) + "\n") + _write_atomically(directory / "current", generation.name + "\n") + return path + + def clear(self, fingerprint: str | None = None) -> int: + """Remove ``current``, every generation and every refinement; return a count. + + Never a live claim. `fingerprint` limits it to one machine and build; ``None`` + clears them all. A stage-2 refinement goes with its machine's profile, since + one fitted over the built-in table would otherwise still apply. + """ + removed = 0 + directories: list[Path] = [] + for kind in ("machines", "problems"): + base = self.root / kind + directories += ( + [base / fingerprint] + if fingerprint is not None + else [path for path in base.glob("*") if path.is_dir()] + ) + for directory in directories: + for path in (directory / "current", *directory.glob("*.json")): + with contextlib.suppress(FileNotFoundError): + path.unlink() + removed += 1 + return removed + + def claim(self, fingerprint: str, allocation: str, deadline: float) -> Claim | None: + """Take the calibration claim on one allocation class, or ``None`` if held. + + The claim file is created exclusively (``O_CREAT | O_EXCL``, on Windows too) + and names the host, process and deadline. A claim whose deadline passed + :data:`STALE_CLAIM_SECONDS` ago, or whose process is gone on this host, is + stale and replaced. + """ + directory = self.directory(fingerprint) + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"claim-{allocation}" + held = Claim(path, socket.gethostname(), os.getpid(), deadline) + payload = json.dumps( + { + "host": held.host, + "pid": held.pid, + "start": time.time(), + "deadline": deadline, + } + ) + for _ in range(2): + try: + handle = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except FileExistsError: + if not self._stale(path): + return None + with contextlib.suppress(FileNotFoundError): + path.unlink() + continue + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(payload) + return held + return None + + def holder(self, fingerprint: str, allocation: str) -> Claim | None: + """Return the live claim on one allocation class, or ``None``.""" + path = self.directory(fingerprint) / f"claim-{allocation}" + if self._stale(path): + return None + try: + held = json.loads(path.read_text(encoding="utf-8")) + return Claim( + path, str(held["host"]), int(held["pid"]), float(held["deadline"]) + ) + except OSError, ValueError, KeyError, TypeError: + return None + + @staticmethod + def _stale(path: Path) -> bool: + """Whether a claim file is gone, unreadable, past its deadline or orphaned.""" + try: + held = json.loads(path.read_text(encoding="utf-8")) + host, pid, deadline = ( + str(held["host"]), + int(held["pid"]), + float(held["deadline"]), + ) + except FileNotFoundError: + return True + except OSError, ValueError, KeyError, TypeError: + # Being written: an exclusive create comes before its contents. + try: + return time.time() - path.stat().st_mtime > STALE_CLAIM_SECONDS + except FileNotFoundError: + return True + if time.time() > deadline + STALE_CLAIM_SECONDS: + return True + return host == socket.gethostname() and not _alive(pid) + + def refinement(self, fingerprint: str, descriptor: str) -> dict[str, object] | None: + """Return one simulation's stage-2 refinement, or ``None``. + + Kept under ``problems//.json``; an unreadable file is + skipped with one :class:`StoreWarning`. + """ + path = self.root / "problems" / fingerprint / f"{descriptor}.json" + try: + found = _mapping(json.loads(path.read_text(encoding="utf-8")), "refinement") + except FileNotFoundError: + return None + except (OSError, ValueError) as error: + _warn(f"ignoring the refinement {path}: {error}") + return None + if found.get("descriptor") != descriptor: + _warn(f"ignoring the refinement {path}: it names another descriptor") + return None + return found + + def publish_refinement( + self, fingerprint: str, descriptor: str, record: Mapping[str, object] + ) -> Path: + """Write one simulation's refinement whole; return its file. + + Raises + ------ + OSError + When the store cannot be written. + """ + directory = self.root / "problems" / fingerprint + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{descriptor}.json" + _write_atomically( + path, json.dumps(dict(record) | {"descriptor": descriptor}, indent=1) + "\n" + ) + return path + + def inconclusive_since(self, fingerprint: str, allocation: str) -> float | None: + """Return when this class's last calibration was inconclusive, or ``None``.""" + try: + attempts = json.loads( + (self.directory(fingerprint) / "attempts.json").read_text( + encoding="utf-8" + ) + ) + value = attempts.get(allocation) + return float(value) if value is not None else None + except OSError, ValueError, AttributeError, TypeError: + return None + + def record_inconclusive( + self, fingerprint: str, allocation: str, when: float | None = None + ) -> None: + """Note an inconclusive calibration of one class; nothing is published.""" + directory = self.directory(fingerprint) + directory.mkdir(parents=True, exist_ok=True) + path = directory / "attempts.json" + try: + attempts = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(attempts, dict): + attempts = {} + except OSError, ValueError: + attempts = {} + attempts = cast(dict[str, object], attempts) + attempts[allocation] = time.time() if when is None else when + _write_atomically(path, json.dumps(attempts) + "\n") + + +def lookup( + fingerprint: str, + stores: Sequence[Store], +) -> Generation | None: + """Return the first published generation of `fingerprint` among `stores`. + + The user's store comes first, then each read-only directory; one with no + generation, or an unreadable one, is passed over. + """ + for store in stores: + generation = store.current(fingerprint) + if generation is not None: + return generation + return None + + +def refinement( + fingerprint: str, descriptor: str, stores: Sequence[Store] +) -> dict[str, object] | None: + """Return the first refinement of `descriptor` among `stores`, or ``None``.""" + for store in stores: + found = store.refinement(fingerprint, descriptor) + if found is not None: + return found + return None + + +def generation_name() -> str: + """Return a new generation's name: its UTC time, process and a random suffix.""" + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + return f"g{stamp}-{os.getpid()}-{secrets.token_hex(3)}" + + +def _write_atomically(path: Path, text: str) -> None: + """Write `text` beside `path`, then replace `path` with it.""" + temporary = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(3)}") + try: + temporary.write_text(text, encoding="utf-8") + temporary.replace(path) + finally: + with contextlib.suppress(FileNotFoundError): + temporary.unlink() + + +def _alive(pid: int) -> bool: + """Whether process `pid` exists on this host.""" + if sys.platform == "win32": + import ctypes + + kernel = ctypes.windll.kernel32 + handle = kernel.OpenProcess( + 0x1000, False, pid + ) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + return False + kernel.CloseHandle(handle) + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +_WARNED: set[str] = set() + + +def _warn(message: str) -> None: + """Issue `message` once per process as a :class:`StoreWarning`.""" + if message not in _WARNED: + _WARNED.add(message) + warnings.warn(message, StoreWarning, stacklevel=3) + + +def stores(environ: Mapping[str, str] = os.environ) -> tuple[Store, ...]: + """Return the user's store, then each read-only directory's.""" + return ( + Store(cache_directory(environ)), + *(Store(root) for root in search_path(environ)), + ) diff --git a/native/src/solprop_native/_topology.py b/native/src/solprop_native/_topology.py new file mode 100644 index 000000000..9caed64d6 --- /dev/null +++ b/native/src/solprop_native/_topology.py @@ -0,0 +1,392 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The machine's processor topology, read without starting the native runtime. + +solprop SEP 46 keys a calibration's rows by *allocation class* (section 6.4): which +cores a process holds, of which kinds, and how many caches, NUMA nodes and sockets +they span. This module reads what that needs: + +- Linux: sysfs (``/sys/devices/system/cpu``): each logical CPU's core and package, its + L2 and L3 groups (``cache/index*/shared_cpu_list``), its NUMA node, and its kind + where the kernel reports one (``cpu_capacity`` on Arm hybrids, the ``cpu_core`` and + ``cpu_atom`` PMUs on Intel hybrids). +- macOS: ``hw.perflevel*`` from ``sysctl``: cores of each kind, fastest first, and + how many share an L2. macOS exposes no CPU affinity, so its CPUs are numbered here + by kind, fastest first. +- Windows: ``GetLogicalProcessorInformation``: cores, caches, NUMA nodes and packages + of the first processor group (64 logical CPUs). Core kinds are not read. + +Elsewhere, or when a source is unreadable, every CPU is its own core on one socket. + +This module is standard library only and loads nothing of the package. +""" + +from __future__ import annotations + +import os +import platform +import sys +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +__all__ = ["Topology", "parse_cpu_list", "read_topology"] + + +@dataclass(frozen=True) +class Topology: + """Each logical CPU's place in the machine. + + Every mapping is keyed by logical CPU and holds a group number, equal for CPUs in + the same group. + + Attributes + ---------- + cpus : tuple of int + Every online logical CPU. + core, socket, numa, l2, l3 : Mapping[int, int] + The physical core, package, NUMA node, and L2 and L3 cache groups. + kind : Mapping[int, int] + The core's kind, 0 the fastest; all 0 on a machine with one kind. + model : str + The processor model, as the platform names it. + """ + + cpus: tuple[int, ...] + core: Mapping[int, int] + socket: Mapping[int, int] + numa: Mapping[int, int] + l2: Mapping[int, int] + l3: Mapping[int, int] + kind: Mapping[int, int] = field(default_factory=dict[int, int]) + model: str = "" + + def record(self) -> dict[str, object]: + """Return the topology as JSON data, the part a fingerprint digests.""" + return { + "model": self.model, + "cpus": len(self.cpus), + "cores": len(set(self.core.values())), + "sockets": len(set(self.socket.values())), + "numa": len(set(self.numa.values())), + "l2": _shares(self.l2, self.cpus), + "l3": _shares(self.l3, self.cpus), + "kinds": _kind_counts(self, self.cpus), + "smt": any( + sum(1 for other in self.cpus if self.core[other] == self.core[cpu]) > 1 + for cpu in self.cpus + ), + } + + +def _shares(groups: Mapping[int, int], cpus: Sequence[int]) -> list[int]: + """Return the sizes of `groups` over `cpus`, sorted, largest first.""" + sizes: dict[int, int] = {} + for cpu in cpus: + sizes[groups[cpu]] = sizes.get(groups[cpu], 0) + 1 + return sorted(sizes.values(), reverse=True) + + +def _kind_counts(topology: Topology, cpus: Sequence[int]) -> list[int]: + """Return the physical cores of each kind among `cpus`, fastest first.""" + cores: dict[int, set[int]] = {} + for cpu in cpus: + cores.setdefault(topology.kind.get(cpu, 0), set()).add(topology.core[cpu]) + return [len(cores[kind]) for kind in sorted(cores)] + + +def parse_cpu_list(text: str) -> tuple[int, ...]: + """Parse a kernel CPU list such as ``"0-3,8,10-11"``. + + Examples + -------- + >>> parse_cpu_list("0-3,8,10-11") + (0, 1, 2, 3, 8, 10, 11) + >>> parse_cpu_list("") + () + """ + cpus: list[int] = [] + for part in text.strip().split(","): + if not part: + continue + first, _, last = part.partition("-") + cpus.extend(range(int(first), int(last or first) + 1)) + return tuple(cpus) + + +def read_topology( + system: str | None = None, + *, + root: Path = Path("/sys/devices/system/cpu"), + sysctl: Callable[[str], int | None] | None = None, +) -> Topology: + """Return this machine's topology. + + `root` and `sysctl` replace the Linux sysfs root and the macOS reader, for tests. + """ + system = system or platform.system() + if system == "Linux": + found = _linux(root) + if found is not None: + return found + elif system == "Darwin": + found = _macos(sysctl or _sysctl_int) + if found is not None: + return found + elif system == "Windows": # pragma: no cover - Windows + found = _windows() + if found is not None: + return found + count = os.cpu_count() or 1 + return _flat(tuple(range(count)), _model(system)) + + +def _flat(cpus: tuple[int, ...], model: str) -> Topology: + """Every CPU its own core, on one socket, node and cache.""" + every = dict.fromkeys(cpus, 0) + return Topology( + cpus=cpus, + core={cpu: cpu for cpu in cpus}, + socket=every, + numa=every, + l2={cpu: cpu for cpu in cpus}, + l3=every, + model=model, + ) + + +def _read(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8").strip() + except OSError: + return None + + +def _linux(root: Path) -> Topology | None: + """Read sysfs; ``None`` when it holds no CPUs.""" + online = _read(root / "online") + cpus = ( + parse_cpu_list(online) + if online + else tuple( + sorted( + int(path.name[3:]) + for path in root.glob("cpu[0-9]*") + if path.name[3:].isdigit() + ) + ) + ) + if not cpus: + return None + core: dict[int, int] = {} + socket: dict[int, int] = {} + numa: dict[int, int] = {} + caches: dict[int, dict[int, int]] = {2: {}, 3: {}} + capacity: dict[int, int] = {} + cores: dict[tuple[int, int], int] = {} + for cpu in cpus: + base = root / f"cpu{cpu}" + package = int(_read(base / "topology" / "physical_package_id") or 0) + core_id = int(_read(base / "topology" / "core_id") or cpu) + socket[cpu] = package + core[cpu] = cores.setdefault((package, core_id), len(cores)) + nodes = [path.name for path in base.glob("node[0-9]*")] + numa[cpu] = int(nodes[0][4:]) if nodes else 0 + for index in base.glob("cache/index[0-9]*"): + level = _read(index / "level") + kind = _read(index / "type") + shared = _read(index / "shared_cpu_list") + if level in {"2", "3"} and kind in {"Unified", "Data"} and shared: + caches[int(level)][cpu] = min(parse_cpu_list(shared)) + value = _read(base / "cpu_capacity") + if value and value.isdigit(): + capacity[cpu] = int(value) + kind = _linux_kinds(cpus, capacity, root.parent.parent) + return Topology( + cpus=cpus, + core=core, + socket=socket, + numa=numa, + l2={cpu: caches[2].get(cpu, core[cpu]) for cpu in cpus}, + l3={cpu: caches[3].get(cpu, socket[cpu]) for cpu in cpus}, + kind=kind, + model=_linux_model(), + ) + + +def _linux_kinds( + cpus: Sequence[int], capacity: Mapping[int, int], devices: Path +) -> dict[int, int]: + """Rank each CPU's kind, 0 the fastest: by capacity, or Intel's hybrid PMUs.""" + if len(set(capacity.values())) > 1: + ranks = sorted(set(capacity.values()), reverse=True) + return {cpu: ranks.index(capacity.get(cpu, ranks[-1])) for cpu in cpus} + big = _read(devices / "cpu_core" / "cpus") + little = _read(devices / "cpu_atom" / "cpus") + if big and little: + fast = set(parse_cpu_list(big)) + return {cpu: 0 if cpu in fast else 1 for cpu in cpus} + return dict.fromkeys(cpus, 0) + + +def _linux_model() -> str: + text = _read(Path("/proc/cpuinfo")) or "" + for line in text.splitlines(): + name, _, value = line.partition(":") + if name.strip() in {"model name", "Model", "CPU part"} and value.strip(): + return value.strip() + return platform.processor() or platform.machine() + + +def _macos(sysctl: Callable[[str], int | None]) -> Topology | None: + """Number the cores by kind, fastest first; ``None`` without ``hw.perflevel*``.""" + levels = sysctl("hw.nperflevels") or 1 + cpus: list[int] = [] + core: dict[int, int] = {} + l2: dict[int, int] = {} + kind: dict[int, int] = {} + for level in range(levels): + count = sysctl(f"hw.perflevel{level}.physicalcpu") + if count is None: + if level == 0: + count = sysctl("hw.physicalcpu") + if count is None: + return None + per_l2 = sysctl(f"hw.perflevel{level}.cpusperl2") or count + first = len(cpus) + for offset in range(count): + cpu = first + offset + cpus.append(cpu) + core[cpu] = cpu + kind[cpu] = level + l2[cpu] = first + (offset // max(per_l2, 1)) * per_l2 + every = dict.fromkeys(cpus, 0) + return Topology( + cpus=tuple(cpus), + core=core, + socket=every, + numa=every, + l2=l2, + l3=every, + kind=kind, + model=_macos_model(), + ) + + +def _macos_model() -> str: # pragma: no cover - macOS + import ctypes + + size = ctypes.c_size_t(0) + name = b"machdep.cpu.brand_string" + try: + query = ctypes.CDLL(None).sysctlbyname + except AttributeError, OSError: + return platform.processor() + if query(name, None, ctypes.byref(size), None, 0) != 0: + return platform.processor() + buffer = ctypes.create_string_buffer(size.value) + if query(name, buffer, ctypes.byref(size), None, 0) != 0: + return platform.processor() + return buffer.value.decode(errors="replace") + + +def _sysctl_int(name: str) -> int | None: # pragma: no cover - macOS + """Read an integer ``sysctl`` value by name on macOS, or ``None``.""" + import ctypes + + value = ctypes.c_int(0) + size = ctypes.c_size_t(ctypes.sizeof(value)) + try: + found = ctypes.CDLL(None).sysctlbyname( + name.encode(), ctypes.byref(value), ctypes.byref(size), None, 0 + ) + except AttributeError, OSError: + return None + return value.value if found == 0 else None + + +def _model(system: str) -> str: + if system == "Linux": + return _linux_model() + return platform.processor() or platform.machine() + + +def _windows() -> Topology | None: # pragma: no cover - Windows + """Read ``GetLogicalProcessorInformation``'s first processor group.""" + if sys.platform != "win32": + return None + import ctypes + + class _Cache(ctypes.Structure): + _fields_ = ( + ("level", ctypes.c_ubyte), + ("associativity", ctypes.c_ubyte), + ("line_size", ctypes.c_ushort), + ("size", ctypes.c_ulong), + ("type", ctypes.c_int), + ) + + class _Union(ctypes.Union): + _fields_ = ( + ("flags", ctypes.c_ubyte), + ("node", ctypes.c_ulong), + ("cache", _Cache), + ("reserved", ctypes.c_ulonglong * 2), + ) + + class _Information(ctypes.Structure): + _fields_ = ( + ("mask", ctypes.c_size_t), + ("relationship", ctypes.c_int), + ("union", _Union), + ) + + query = ctypes.windll.kernel32.GetLogicalProcessorInformation + size = ctypes.c_ulong(0) + query(None, ctypes.byref(size)) + table = (_Information * (size.value // ctypes.sizeof(_Information)))() + if not query(table, ctypes.byref(size)): + return None + + def members(mask: int) -> list[int]: + return [bit for bit in range(mask.bit_length()) if mask >> bit & 1] + + core: dict[int, int] = {} + socket: dict[int, int] = {} + numa: dict[int, int] = {} + l2: dict[int, int] = {} + l3: dict[int, int] = {} + packages = 0 + for entry in table: + cpus = members(entry.mask) + if not cpus: + continue + if entry.relationship == 0: # RelationProcessorCore + for cpu in cpus: + core[cpu] = min(cpus) + elif entry.relationship == 1: # RelationNumaNode + for cpu in cpus: + numa[cpu] = int(entry.union.node) + elif entry.relationship == 2: # RelationCache + target = {2: l2, 3: l3}.get(int(entry.union.cache.level)) + if target is not None: + for cpu in cpus: + target[cpu] = min(cpus) + elif entry.relationship == 3: # RelationProcessorPackage + for cpu in cpus: + socket[cpu] = packages + packages += 1 + all_cpus = tuple(sorted(core)) + if not all_cpus: + return None + return Topology( + cpus=all_cpus, + core=core, + socket={cpu: socket.get(cpu, 0) for cpu in all_cpus}, + numa={cpu: numa.get(cpu, 0) for cpu in all_cpus}, + l2={cpu: l2.get(cpu, core[cpu]) for cpu in all_cpus}, + l3={cpu: l3.get(cpu, 0) for cpu in all_cpus}, + model=platform.processor(), + ) diff --git a/native/src/solprop_native/_wait.py b/native/src/solprop_native/_wait.py index 64b7a1730..6586708d5 100644 --- a/native/src/solprop_native/_wait.py +++ b/native/src/solprop_native/_wait.py @@ -38,7 +38,7 @@ from __future__ import annotations import os -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from dataclasses import dataclass VARIABLES = ("OMP_WAIT_POLICY", "GOMP_SPINCOUNT", "KMP_BLOCKTIME") @@ -53,6 +53,14 @@ MARKER = "SOLPROP_NATIVE_WAIT" """Set beside the variables this module sets, naming the choice it made.""" +REQUEST = "SOLPROP_NATIVE_WAIT_REQUEST" +"""solprop's own request for a process, ``spin`` or ``passive`` (solprop SEP 46). + +A scan's workers and a calibration's children ask for a wait through it. A user's +:data:`VARIABLES` still win, and so does a calibration's row for the process's +allocation; the request replaces the default rule's choice. +""" + _SETTINGS = { "spin": {"GOMP_SPINCOUNT": SPIN_COUNT, "KMP_BLOCKTIME": "0"}, "passive": {"OMP_WAIT_POLICY": "passive"}, @@ -81,18 +89,36 @@ def _ours(environ: MutableMapping[str, str]) -> bool: ) +def requested_wait(environ: Mapping[str, str] = os.environ) -> str | None: + """Return :data:`REQUEST`'s ``spin`` or ``passive``, or ``None``. + + Raises + ------ + ValueError + For another value. + """ + value = environ.get(REQUEST, "").strip() + if not value: + return None + if value not in _SETTINGS: + raise ValueError(f"{REQUEST} must be spin or passive, got {value!r}") + return value + + def wait_policy( environ: MutableMapping[str, str] = os.environ, *, threads: int = 1, top_tier: int | None = None, + preferred: str | None = None, ) -> WaitPolicy: """Set the default wait in `environ` unless the user chose; say which. `threads` is the native pool's size and `top_tier` the cores of the chip's fastest - kind where it has several (``None`` otherwise). Returns ``"spin"`` or - ``"passive"`` when set here, else ``"environment: NAME=value"`` for the first of - :data:`VARIABLES` the user set. + kind where it has several (``None`` otherwise). `preferred`, ``"spin"`` or + ``"passive"``, is a machine calibration's choice (solprop SEP 46), which replaces + the default rule. Returns ``"spin"`` or ``"passive"`` when set here, else + ``"environment: NAME=value"`` for the first of :data:`VARIABLES` the user set. """ if not _ours(environ): for name in VARIABLES: @@ -102,7 +128,7 @@ def wait_policy( for name in (*VARIABLES, MARKER): environ.pop(name, None) wider = top_tier is not None and threads > top_tier - choice = "passive" if wider else "spin" + choice = preferred if preferred in _SETTINGS else "passive" if wider else "spin" environ.update(_SETTINGS[choice]) environ[MARKER] = choice return WaitPolicy(choice, SPIN_MILLISECONDS if choice == "spin" else 0) diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 10655aebd..1048e4a6d 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -151,6 +151,10 @@ _transverse_edge_bytes, # pyright: ignore[reportPrivateUsage] - released together ) +# The profile's state is read from the package at each use: a first-use calibration in +# this process refreshes it (solprop SEP 46 section 5). +import solprop_native as _package + from . import ( PROC_BIND, THREAD_BUDGET, @@ -163,6 +167,7 @@ build_info, check_build, ) +from ._execution import ExecutionRecord from ._helpers import NATIVE type ComplexArray = npt.NDArray[np.complex128] @@ -326,6 +331,8 @@ def __init__( modes: Sequence[Mode] | None = None, profiles: Mapping[str, ZProfile] | None = None, adams_march: Literal["native", "host"] = "native", + cpu_execution: ExecutionRecord | None = None, + fft_planning: Mapping[str, object] | None = None, ) -> None: if _BENCHMARK_GATED.get(): raise BackendError("the gated plasma benchmark requires numpy_ref") @@ -361,10 +368,37 @@ def __init__( wisdom = _wisdom.wisdom_file() self._wisdom_loaded = _wisdom.load(wisdom) self._bound_buckets: np.ndarray | None = None + # solprop SEP 46: the workspace's CPU execution record, the process's (the + # calibration's where it holds, else the built-in table) unless one is given, + # as a stage-2 refinement gives one; read back from the state it built. + self._execution_source = ( + _package.EXECUTION_SOURCE if cpu_execution is None else "given" + ) self._native_state = _core.State( _native_problem(problem, self._terms, self._flow, self._z_tables) | _low_storage_payload(problem, device=_on_device()) + | {"cpu_execution": (cpu_execution or _package.EXECUTION).native()} + | _planning_payload(fft_planning) ) + self._applied_execution = ExecutionRecord.from_native( + self._native_state.cpu_execution + ) + if cpu_execution is None: + # A stage-2 refinement of this very execution (solprop SEP 46 section + # 6.6): the workspace is built again with its record. + refined = _refined_record(problem, self._native_state) + if refined is not None: + self._native_state.close() + self._native_state = _core.State( + _native_problem(problem, self._terms, self._flow, self._z_tables) + | _low_storage_payload(problem, device=_on_device()) + | {"cpu_execution": refined[0].native()} + | _planning_payload(fft_planning) + ) + self._execution_source = refined[1] + self._applied_execution = ExecutionRecord.from_native( + self._native_state.cpu_execution + ) self._native_state.set_crossing_workers(thread_budget().threads) _wisdom.store(wisdom) self._wisdom_digest: str | None = None @@ -1968,6 +2002,13 @@ def metadata(self) -> dict[str, object]: "thread_budget_source": THREAD_BUDGET.source, "openmp_wait_policy": WAIT_POLICY, "openmp_proc_bind": PROC_BIND, + "calibration": _package.PROFILE.record( + _package.PROCESS, + self._applied_execution, + _package.BUILTIN_EXECUTION, + self._execution_source, + ) + | _planning_record(self._native_state), "host_blas": self._host_blas, "planning_seconds": self._native_state.planning_seconds, "wisdom_loaded": self._wisdom_loaded, @@ -4407,6 +4448,69 @@ def _aperture_payload( ) +def _refined_record( + problem: KernelProblem, state: _core.State +) -> tuple[ExecutionRecord, str] | None: + """Return a stored stage-2 refinement of this execution, or ``None``. + + Its record applies only while the machine record it refined is still the + process's: a refinement adds to the machine profile and never outlives it. + """ + if _package.PROFILE.fingerprint is None: + return None + from ._profile import descriptor + from ._store import refinement, stores + + key = descriptor( + shape=problem.shape, + grid_kind=problem.grid_kind, + integrator=type(problem.stepper.integrator).__name__, + sizing=state.execution_sizing, + process=_package.PROCESS, + fingerprint=_package.PROFILE.fingerprint.digest, + ) + found = refinement(_package.PROFILE.fingerprint.digest, key, stores()) + if found is None or found.get("base") != _package.EXECUTION.native(): + return None + changes = found.get("workspace") + if not isinstance(changes, dict): + return None + try: + return _package.EXECUTION.updated( + cast(dict[str, object], changes) + ), f"refinement {key}" + except ValueError: + return None + + +def _planning_payload(given: Mapping[str, object] | None) -> dict[str, object]: + """Return the problem's ``fft_planning``: the one given, the profile's, or none.""" + chosen = given if given is not None else _package.PLANNING + if chosen is None or _on_device(): + return {} + return {"fft_planning": dict(chosen)} + + +def _planning_record(state: _core.State) -> dict[str, object]: + """Return what a workspace's FFT planning applied, for its run's metadata. + + The plans keyed in its profile that no longer build from their wisdom are listed: + they ran at estimate (solprop SEP 46 section 6.7). + """ + if _on_device(): + return {} + found = state.fft_planning + groups = cast(dict[str, str], found["groups"]) + if not groups: + return {"fft_planning": "policy"} + return { + "fft_planning": { + "plans": len(groups), + "missing": list(cast(list[str], found["missing"])), + } + } + + def _native_problem( problem: KernelProblem, fixed: StageTerms, @@ -4552,6 +4656,9 @@ def _native_problem( ) ) return { + # solprop SEP 46: how the workspace's CPU operations thread, the process's + # record unless the backend was given one. + "cpu_execution": _package.EXECUTION.native(), **spatial_data, **_channel_payload(problem), **_envelope_payload(problem), diff --git a/native/tests/conftest.py b/native/tests/conftest.py index 64db7e669..8d97deb25 100644 --- a/native/tests/conftest.py +++ b/native/tests/conftest.py @@ -11,6 +11,8 @@ from __future__ import annotations +import os + import numpy as np import numpy.typing as npt import pytest @@ -20,6 +22,10 @@ from solprop.stepper import PIController from solprop.translate.problem import KernelProblem, assemble +# A machine calibration (solprop SEP 46) neither runs nor applies in the suite unless a +# test asks for one, so that a developer's store cannot change what a test measures. +os.environ.setdefault("SOLPROP_CALIBRATION", "off") + # Trigger the documented macOS runtime ordering without adding an unused binding. __import__("solprop_native") diff --git a/native/tests/native/execution.hpp b/native/tests/native/execution.hpp new file mode 100644 index 000000000..bf751b1c2 --- /dev/null +++ b/native/tests/native/execution.hpp @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 John C. Travers and contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +// The CPU execution record the native tests build workspaces with (solprop SEP 46): +// solprop_native's built-in table for this platform with idle threads asleep, as +// `_execution.builtin` returns it without a spin. Native code holds no values of its +// own; a workspace refuses a problem without a record. + +#include "solprop_native/core.hpp" + +namespace solprop_native::test { + +inline CpuExecution make_table() { + CpuExecution table; +#if defined(__APPLE__) + constexpr std::size_t stage = 8192; + constexpr std::size_t fft = 32768; + constexpr bool stable = false; +#else + constexpr std::size_t stage = 4096; + constexpr std::size_t fft = 16384; + constexpr bool stable = true; +#endif + const auto set = [&](CpuOperation operation, std::size_t threshold, + std::size_t full) { + table.grain[static_cast(operation)] = {threshold, full}; + }; + set(CpuOperation::stage, stage, stage); + set(CpuOperation::response, 32768, 196608); + set(CpuOperation::dense, 1048576, 1048576); + set(CpuOperation::fft, fft, 131072); + set(CpuOperation::raman, 65536, 262144); + set(CpuOperation::copy, 65536, 65536); + set(CpuOperation::flow, 32768, 32768); + set(CpuOperation::modal, 65536, 65536); + set(CpuOperation::adams, 8192, 65536); + table.response_full_samples = 65536; + table.response_full_columns = 40; + table.medium_threads = 4; + table.compact_threads = 8; + table.compact_max_fine_samples = 65536; + table.stable_kernel_team = stable; + return table; +} + +/** The record every test problem and pool takes unless a test sets its own. */ +inline const CpuExecution& table() { + static const CpuExecution value = make_table(); + return value; +} + +} // namespace solprop_native::test diff --git a/native/tests/native/test_core.cpp b/native/tests/native/test_core.cpp index bc2f7bcf6..3040ff9fc 100644 --- a/native/tests/native/test_core.cpp +++ b/native/tests/native/test_core.cpp @@ -7,6 +7,7 @@ #include "solprop_native/plasma.hpp" #include "bounds.hpp" +#include "execution.hpp" #include @@ -93,6 +94,7 @@ std::vector real_time(const std::vector& spectrum, native::Problem base_problem(const native::FieldFamily family) { native::Problem problem; + problem.cpu_execution = native::test::table(); problem.n_t = family == native::FieldFamily::envelope ? 4 : 4; problem.n_to = family == native::FieldFamily::envelope ? 4 : 8; problem.n_omega = family == native::FieldFamily::envelope ? 4 : 3; @@ -1136,12 +1138,12 @@ TEST(GeneralState, MemoryLedgerMatchesConditionalAllocationFormulae) { // One slot per thread of the response operation's own width (problem-size policy). const auto response_elements = problem.node_chunk * problem.effective_node_pol() * problem.n_to; - const int width = - native::cpu_size_policy_enabled - ? native::operation_threads(native::thread_policy(), native::CpuPool::kernels, - native::CpuOperation::response, response_elements, - problem.n_to) - : native::ExecutionSpace().concurrency(); + const int width = native::cpu_size_policy_enabled + ? native::operation_threads( + native::thread_policy(), native::test::table(), + native::CpuPool::kernels, native::CpuOperation::response, + response_elements, problem.n_to) + : native::ExecutionSpace().concurrency(); const auto slots = std::min(problem.node_chunk, static_cast(std::max(1, width))); EXPECT_EQ(ledger.at("plasma_device_scan"), 3 * slots * problem.n_to * real); @@ -2735,24 +2737,31 @@ TEST(CpuThreadPolicy, AutomaticBoundariesAreMonotoneAndCapped) { CpuOperation::fft, CpuOperation::raman, CpuOperation::copy, CpuOperation::flow, CpuOperation::modal, CpuOperation::adams}; const std::array thresholds{ - native::stage_thread_min_elements, native::response_thread_min_elements, - native::dense_thread_min_elements, native::default_threaded_fft_min_samples, - native::raman_thread_min_elements, native::copy_thread_min_elements, - native::flow_thread_min_elements, native::modal_thread_min_elements, - native::adams_thread_min_elements}; + native::test::table()[native::CpuOperation::stage].threshold, + native::test::table()[native::CpuOperation::response].threshold, + native::test::table()[native::CpuOperation::dense].threshold, + native::test::table()[native::CpuOperation::fft].threshold, + native::test::table()[native::CpuOperation::raman].threshold, + native::test::table()[native::CpuOperation::copy].threshold, + native::test::table()[native::CpuOperation::flow].threshold, + native::test::table()[native::CpuOperation::modal].threshold, + native::test::table()[native::CpuOperation::adams].threshold}; const std::array full_thresholds{ - native::stage_full_thread_elements, native::response_full_thread_elements, - native::dense_full_thread_elements, native::fft_full_thread_elements, - native::raman_full_thread_elements, native::copy_thread_min_elements, - native::flow_thread_min_elements, native::modal_thread_min_elements, - native::adams_full_thread_elements}; + native::test::table()[native::CpuOperation::stage].full, + native::test::table()[native::CpuOperation::response].full, + native::test::table()[native::CpuOperation::dense].full, + native::test::table()[native::CpuOperation::fft].full, + native::test::table()[native::CpuOperation::raman].full, + native::test::table()[native::CpuOperation::copy].threshold, + native::test::table()[native::CpuOperation::flow].threshold, + native::test::table()[native::CpuOperation::modal].threshold, + native::test::table()[native::CpuOperation::adams].full}; for (const int cap : {1, 2, 3, 8, 72}) { - native::ThreadPolicy policy{cap, cap, cap, cap, - native::default_threaded_fft_min_samples}; + native::ThreadPolicy policy{cap, cap, cap, cap}; for (std::size_t index = 0; index < operations.size(); ++index) { const auto width = [&](const std::size_t elements) { - return native::operation_threads(policy, CpuPool::kernels, operations[index], - elements); + return native::operation_threads(policy, native::test::table(), + CpuPool::kernels, operations[index], elements); }; EXPECT_EQ(width(0), native::cpu_size_policy_enabled ? 1 : cap); EXPECT_EQ(width(thresholds[index] - 1), @@ -2760,7 +2769,7 @@ TEST(CpuThreadPolicy, AutomaticBoundariesAreMonotoneAndCapped) { EXPECT_EQ(width(thresholds[index]), native::cpu_size_policy_enabled ? (thresholds[index] < full_thresholds[index] - ? std::min(cap, native::cpu_medium_threads) + ? std::min(cap, native::test::table().medium_threads) : cap) : cap); EXPECT_EQ(width(full_thresholds[index]), cap); @@ -2777,15 +2786,18 @@ TEST(CpuThreadPolicy, AutomaticBoundariesAreMonotoneAndCapped) { } TEST(CpuThreadPolicy, ShortCopiesDoNotInheritTheStageWidth) { - native::ThreadPolicy policy{18, 18, 18, 18, native::default_threaded_fft_min_samples}; - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + native::ThreadPolicy policy{18, 18, 18, 18}; + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::copy, 8192), native::cpu_size_policy_enabled ? 1 : 18); - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::stage, 8196), 18); policy.explicit_pools = 1; - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::copy, 8192), 18); } @@ -2798,9 +2810,10 @@ TEST(CpuThreadPolicy, SmoothGridSizesKeepThePowerOfTwoWidths) { #if defined(__APPLE__) GTEST_SKIP() << "macOS keeps the 8192/32768 boundaries"; #endif - native::ThreadPolicy policy{18, 18, 18, 18, native::default_threaded_fft_min_samples}; + native::ThreadPolicy policy{18, 18, 18, 18}; const auto width = [&](const native::CpuOperation operation, const std::size_t n) { - return native::operation_threads(policy, native::CpuPool::kernels, operation, n); + return native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, operation, n); }; using native::CpuOperation; EXPECT_EQ(width(CpuOperation::stage, 8004), width(CpuOperation::stage, 8196)); @@ -2810,9 +2823,10 @@ TEST(CpuThreadPolicy, SmoothGridSizesKeepThePowerOfTwoWidths) { } TEST(CpuThreadPolicy, CoupledMatrixWindowRetainsFullWidth) { - native::ThreadPolicy policy{72, 72, 72, 72, 32768, -1}; + native::ThreadPolicy policy{72, 72, 72, 72, -1}; // Active bins * modes^3 in the measured three-mode wall, independent of runtime. - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::flow, 1433 * 3 * 3 * 3), 72); } @@ -2821,25 +2835,24 @@ TEST(CpuThreadPolicy, ResponseShapeThresholdsAreMonotoneAndCapped) { using native::CpuOperation; using native::CpuPool; for (const int cap : {1, 3, 18, 72}) { - native::ThreadPolicy policy{cap, cap, cap, cap, - native::default_threaded_fft_min_samples}; + native::ThreadPolicy policy{cap, cap, cap, cap}; const auto width = [&](const std::size_t rows, const std::size_t samples) { - return native::operation_threads(policy, CpuPool::kernels, CpuOperation::response, - rows * samples, samples); + return native::operation_threads(policy, native::test::table(), CpuPool::kernels, + CpuOperation::response, rows * samples, samples); }; EXPECT_EQ(width(33, 4096), native::cpu_size_policy_enabled ? std::min(cap, 4) : cap); - const auto columns = native::response_full_thread_columns; + const auto columns = native::test::table().response_full_columns; EXPECT_EQ(width(columns - 1, 1024), native::cpu_size_policy_enabled ? std::min(cap, 4) : cap); EXPECT_EQ(width(columns, 1024), cap); EXPECT_EQ(width(columns, 1), native::cpu_size_policy_enabled ? 1 : cap); EXPECT_EQ(width(44, 2048), cap); EXPECT_EQ(width(68, 2048), cap); - EXPECT_EQ(width(1, native::response_full_thread_samples - 1), + EXPECT_EQ(width(1, native::test::table().response_full_samples - 1), native::cpu_size_policy_enabled ? std::min(cap, 4) : cap); - EXPECT_EQ(width(1, native::response_full_thread_samples), cap); - EXPECT_EQ(width(0, native::response_full_thread_samples), + EXPECT_EQ(width(1, native::test::table().response_full_samples), cap); + EXPECT_EQ(width(0, native::test::table().response_full_samples), native::cpu_size_policy_enabled ? 1 : cap); for (std::size_t rows = 1; rows <= 64; rows *= 2) { int previous = 1; @@ -2858,42 +2871,49 @@ TEST(CpuThreadPolicy, ResponseShapeThresholdsAreMonotoneAndCapped) { TEST(CpuThreadPolicy, ExplicitPoolsBypassSizingIndependently) { using native::CpuOperation; using native::CpuPool; - native::ThreadPolicy policy{7, 6, 5, 4, native::default_threaded_fft_min_samples}; + native::ThreadPolicy policy{7, 6, 5, 4}; const std::array pools{CpuPool::kernels, CpuPool::fft, CpuPool::raman_fft, CpuPool::raman_tail_fft}; for (unsigned requested = 0; requested < pools.size(); ++requested) { policy.explicit_pools = 1U << requested; for (unsigned other = 0; other < pools.size(); ++other) { - const auto actual = - native::operation_threads(policy, pools[other], CpuOperation::fft, 1); + const auto actual = native::operation_threads(policy, native::test::table(), + pools[other], CpuOperation::fft, 1); EXPECT_EQ(actual, requested == other || !native::cpu_size_policy_enabled ? 7 - static_cast(other) : 1); } } policy.explicit_pools = 0; - policy.threaded_fft_min_samples = 17; - EXPECT_EQ(native::operation_threads(policy, CpuPool::fft, CpuOperation::fft, 16), - native::cpu_size_policy_enabled ? 1 : 6); - EXPECT_EQ(native::operation_threads(policy, CpuPool::fft, CpuOperation::fft, 17), - native::cpu_size_policy_enabled ? std::min(6, native::cpu_medium_threads) - : 6); + // A record's own FFT start (solprop SEP 46: SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES). + auto execution = native::test::table(); + execution.grain[static_cast(CpuOperation::fft)].threshold = 17; + EXPECT_EQ( + native::operation_threads(policy, execution, CpuPool::fft, CpuOperation::fft, 16), + native::cpu_size_policy_enabled ? 1 : 6); + EXPECT_EQ( + native::operation_threads(policy, execution, CpuPool::fft, CpuOperation::fft, 17), + native::cpu_size_policy_enabled + ? std::min(6, native::test::table().medium_threads) + : 6); } TEST(CpuThreadPolicy, GeneralModalTransformsKeepTheirMeasuredWidth) { - native::ThreadPolicy policy{18, 18, 18, 18, native::default_threaded_fft_min_samples}; + native::ThreadPolicy policy{18, 18, 18, 18}; for (const std::size_t elements : {98496U, 209304U}) { - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::modal, elements), 18); - EXPECT_EQ(native::operation_threads(policy, native::CpuPool::kernels, + EXPECT_EQ(native::operation_threads(policy, native::test::table(), + native::CpuPool::kernels, native::CpuOperation::dense, elements), native::cpu_size_policy_enabled ? 1 : 18); } } TEST(CpuThreadPolicy, MetadataNamesEveryOperationClass) { - native::CpuExecutionPool pool(native::CpuPool::kernels); + native::CpuExecutionPool pool(native::CpuPool::kernels, native::test::table()); const std::array operations{ native::CpuOperation::stage, native::CpuOperation::response, native::CpuOperation::dense, native::CpuOperation::fft, @@ -2948,15 +2968,17 @@ TEST(CpuThreadPolicy, AKernelPoolAtFullWidthKeepsItsTeam) { // operations too; without one, they keep the medium width. On macOS medium // operations keep the medium width beside one too (`stable_kernel_team`). const int cap = native::thread_policy().kernels; - const int promoted = native::stable_kernel_team ? cap : native::cpu_medium_threads; - if (!native::cpu_size_policy_enabled || cap <= native::cpu_medium_threads) + const int promoted = native::test::table().stable_kernel_team + ? cap + : native::test::table().medium_threads; + if (!native::cpu_size_policy_enabled || cap <= native::test::table().medium_threads) GTEST_SKIP() << "needs per-operation sizing and more than the medium width"; const auto response = std::size_t{33} * 4096; - native::CpuExecutionPool alone(native::CpuPool::kernels); + native::CpuExecutionPool alone(native::CpuPool::kernels, native::test::table()); EXPECT_EQ(alone.get(native::CpuOperation::response, response, 4096).concurrency(), - native::cpu_medium_threads); + native::test::table().medium_threads); EXPECT_EQ(alone.get(native::CpuOperation::stage, 1).concurrency(), 1); - native::CpuExecutionPool full(native::CpuPool::kernels); + native::CpuExecutionPool full(native::CpuPool::kernels, native::test::table()); EXPECT_EQ(full.get(native::CpuOperation::stage, 1U << 20).concurrency(), cap); EXPECT_EQ(full.get(native::CpuOperation::response, response, 4096).concurrency(), promoted); @@ -2966,10 +2988,10 @@ TEST(CpuThreadPolicy, AKernelPoolAtFullWidthKeepsItsTeam) { EXPECT_EQ(full.sizing().at(key).at("threads"), static_cast(promoted)); // The FFT pools keep their medium widths: FFTW's threaded loops run on the // runtime's whole team and only distribute fewer iterations. - native::CpuExecutionPool transforms(native::CpuPool::fft); + native::CpuExecutionPool transforms(native::CpuPool::fft, native::test::table()); EXPECT_EQ(transforms.get(native::CpuOperation::fft, 1U << 20).concurrency(), cap); EXPECT_EQ(transforms.get(native::CpuOperation::fft, 32768).concurrency(), - native::cpu_medium_threads); + native::test::table().medium_threads); } TEST(CpuThreadPolicy, ACompactPoolKeepsAFewThreads) { @@ -2979,18 +3001,19 @@ TEST(CpuThreadPolicy, ACompactPoolKeepsAFewThreads) { const auto policy = native::thread_policy(); if (!native::cpu_size_policy_enabled || policy.explicit_pools != 0) GTEST_SKIP() << "needs automatic per-operation sizing"; - const int team = std::min(policy.kernels, native::cpu_compact_threads); - native::CpuExecutionPool kernels(native::CpuPool::kernels); + const int team = std::min(policy.kernels, native::test::table().compact_threads); + native::CpuExecutionPool kernels(native::CpuPool::kernels, native::test::table()); kernels.compact(); EXPECT_EQ(kernels.widest(), 0); EXPECT_EQ(kernels.get(native::CpuOperation::stage, 1U << 20).concurrency(), team); EXPECT_EQ(kernels.get(native::CpuOperation::response, std::size_t{33} * 4096, 4096) .concurrency(), - native::stable_kernel_team ? team - : std::min(team, native::cpu_medium_threads)); + native::test::table().stable_kernel_team + ? team + : std::min(team, native::test::table().medium_threads)); EXPECT_EQ(kernels.get(native::CpuOperation::stage, 1).concurrency(), 1); EXPECT_EQ(kernels.widest(), team); - native::CpuExecutionPool transforms(native::CpuPool::fft); + native::CpuExecutionPool transforms(native::CpuPool::fft, native::test::table()); transforms.compact(); const int threaded = native::fftw_team_loops() ? team : 1; EXPECT_EQ(transforms.get(native::CpuOperation::fft, 1U << 20).concurrency(), @@ -2998,13 +3021,15 @@ TEST(CpuThreadPolicy, ACompactPoolKeepsAFewThreads) { // A medium transform takes the whole team; a Raman one keeps its own start. EXPECT_EQ(transforms.get(native::CpuOperation::fft, 32768).concurrency(), threaded); EXPECT_EQ(transforms.get(native::CpuOperation::fft, 1000).concurrency(), 1); - native::CpuExecutionPool raman(native::CpuPool::raman_fft); + native::CpuExecutionPool raman(native::CpuPool::raman_fft, native::test::table()); raman.compact(); EXPECT_EQ(raman.get(native::CpuOperation::raman, 32768).concurrency(), 1); - EXPECT_EQ(raman.get(native::CpuOperation::raman, native::raman_thread_min_elements) + EXPECT_EQ(raman + .get(native::CpuOperation::raman, + native::test::table()[native::CpuOperation::raman].threshold) .concurrency(), threaded); - native::CpuExecutionPool full(native::CpuPool::kernels); + native::CpuExecutionPool full(native::CpuPool::kernels, native::test::table()); EXPECT_EQ(full.get(native::CpuOperation::stage, 1U << 20).concurrency(), policy.kernels); } @@ -3022,7 +3047,7 @@ TEST(CpuThreadPolicy, CompactProblemsHaveOneColumnOnOneNode) { pairs.n_pol_transform = 2; EXPECT_FALSE(native::compact_problem(pairs)); auto long_column = base; - long_column.n_to = native::compact_max_fine_samples; + long_column.n_to = native::test::table().compact_max_fine_samples; EXPECT_FALSE(native::compact_problem(long_column)); auto modal = base; modal.general_modal = true; @@ -3036,8 +3061,8 @@ TEST(CpuThreadPolicy, CompactProblemsHaveOneColumnOnOneNode) { } TEST(CpuThreadPolicy, MetadataDistinguishesEqualWorkWithDifferentResponseShapes) { - native::CpuExecutionPool pool(native::CpuPool::kernels); - const auto samples = native::response_full_thread_samples; + native::CpuExecutionPool pool(native::CpuPool::kernels, native::test::table()); + const auto samples = native::test::table().response_full_samples; pool.get(native::CpuOperation::response, samples, 4096); pool.get(native::CpuOperation::response, samples, samples); const auto sizing = pool.sizing(); @@ -3080,7 +3105,7 @@ TEST(CpuThreadPolicy, MatrixPackingDoesNotInheritTheDenseWorkWidth) { TEST(CpuThreadPolicy, WorkspacesKeepIndependentChoicesAndReleaseTheirPools) { auto problem = general_problem(native::FieldFamily::envelope); native::GeneralState small(problem); - problem.n_to = native::fft_full_thread_elements; + problem.n_to = native::test::table()[native::CpuOperation::fft].full; problem.time_window.assign(problem.n_to, 1.0); problem.time_window_derivative.assign(problem.n_to, 0.0); problem.carrier.assign(problem.n_to, Complex(1.0, 0.0)); diff --git a/native/tests/native/test_coupled_flow.cpp b/native/tests/native/test_coupled_flow.cpp index 5643f10e7..98455924b 100644 --- a/native/tests/native/test_coupled_flow.cpp +++ b/native/tests/native/test_coupled_flow.cpp @@ -5,6 +5,8 @@ #include "solprop_native/core.hpp" #include "solprop_native/coupled_flow.hpp" +#include "execution.hpp" + #include #include @@ -47,6 +49,7 @@ std::size_t block(const std::size_t count) { return count * kActive * kModes * k native::Problem flow_problem(const bool residual) { native::Problem problem; + problem.cpu_execution = native::test::table(); problem.n_t = 4; problem.n_to = 8; problem.n_omega = kOmega; diff --git a/native/tests/native/test_dispersive.cpp b/native/tests/native/test_dispersive.cpp index 9b1b25912..d024df28a 100644 --- a/native/tests/native/test_dispersive.cpp +++ b/native/tests/native/test_dispersive.cpp @@ -5,6 +5,8 @@ #include "solprop_native/core.hpp" #include "solprop_native/dispersive.hpp" +#include "execution.hpp" + #include #include @@ -277,6 +279,7 @@ TEST(DispersiveMixture, TheRowsAreTheReferenceArithmetic) { native::Problem dispersive_problem() { native::Problem problem; + problem.cpu_execution = native::test::table(); problem.n_t = 4; problem.n_to = 8; problem.n_omega = 3; @@ -442,6 +445,7 @@ native::Problem general_problem(const bool symmetric) { // A layout of the general modal route: three channels, helium in region 3, argon and // krypton in region 5, as solprop's leaf groups them. native::Problem problem; + problem.cpu_execution = native::test::table(); problem.n_to = 8; problem.n_stages = 1; problem.family = native::FieldFamily::field_real; diff --git a/native/tests/native/test_general_modal.cpp b/native/tests/native/test_general_modal.cpp index 81c1ef357..6515fc636 100644 --- a/native/tests/native/test_general_modal.cpp +++ b/native/tests/native/test_general_modal.cpp @@ -5,6 +5,8 @@ #include "solprop_native/core.hpp" #include "solprop_native/general_modal.hpp" +#include "execution.hpp" + #include #include @@ -60,6 +62,7 @@ native::Problem modal_problem(const bool real_form, const std::size_t chunk, const std::size_t offset = 0, const std::size_t n_to = 8) { native::Problem problem; + problem.cpu_execution = native::test::table(); problem.n_t = n_to / 2; problem.n_to = n_to; problem.n_omega = n_omega; diff --git a/native/tests/test_api_contract.py b/native/tests/test_api_contract.py index 3012aff23..79509eb10 100644 --- a/native/tests/test_api_contract.py +++ b/native/tests/test_api_contract.py @@ -96,10 +96,12 @@ def test_build_info_is_frozen_and_complete() -> None: info.thread_count = 99 # type: ignore[misc] assert len(info.source_digest) == 64 assert info.response_full_thread_columns > 0 + # The work sizes are the process's built-in table (solprop SEP 46), not build facts. assert ( info.response_full_thread_columns - == _core.build_record()["response_full_thread_columns"] + == native.BUILTIN_EXECUTION.response_full_columns ) + assert "response_full_thread_columns" not in _core.build_record() assert isinstance(info.coverage_instrumented, bool) assert info.coverage_instrumented == _core.build_record()["coverage_instrumented"] if info.coverage_instrumented: diff --git a/native/tests/test_execution.py b/native/tests/test_execution.py new file mode 100644 index 000000000..ad7cd444a --- /dev/null +++ b/native/tests/test_execution.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A workspace's CPU execution record and the built-in table (solprop SEP 46).""" + +from __future__ import annotations + +import dataclasses +import os +import platform + +import pytest +from conftest import PhaseACase + +import solprop_native as native +from solprop_native import _core +from solprop_native._execution import ( + SPIN_DIVISOR, + SPIN_MAX_TEAM, + ExecutionRecord, + builtin, +) +from solprop_native._wait import SPIN_MILLISECONDS +from solprop_native.backend import ( + NativeBackend, + _native_problem, # pyright: ignore[reportPrivateUsage] +) + +# The constants of core.hpp before SEP 46 moved them here (solprop PR #576), Linux and +# Windows: every value of the table, frozen so that a machine without a profile runs +# exactly as before. +LINUX = { + "stage_threshold": 4096, + "stage_full": 4096, + "response_threshold": 32768, + "response_full": 196608, + "dense_threshold": 1048576, + "dense_full": 1048576, + "fft_threshold": 16384, + "fft_full": 131072, + "raman_threshold": 65536, + "raman_full": 262144, + "copy_threshold": 65536, + "copy_full": 65536, + "flow_threshold": 32768, + "flow_full": 32768, + "modal_threshold": 65536, + "modal_full": 65536, + "adams_threshold": 8192, + "adams_full": 65536, + "response_full_samples": 65536, + "response_full_columns": 40, + "medium_threads": 4, + "compact_threads": 8, + "compact_max_fine_samples": 65536, + "stable_kernel_team": True, +} +# macOS: its own stage and FFT starts and no stable kernel team. +APPLE = LINUX | { + "stage_threshold": 8192, + "stage_full": 8192, + "fft_threshold": 32768, + "stable_kernel_team": False, +} +# macOS on a spinning team of at most eight: every threshold and full size divided by +# 16 (ADR-073), each full size kept at least its threshold. +APPLE_SPINNING = APPLE | { + "stage_threshold": 512, + "stage_full": 512, + "response_threshold": 2048, + "response_full": 12288, + "dense_threshold": 65536, + "dense_full": 65536, + "fft_threshold": 2048, + "fft_full": 8192, + "raman_threshold": 4096, + "raman_full": 16384, + "copy_threshold": 4096, + "copy_full": 4096, + "flow_threshold": 2048, + "flow_full": 2048, + "modal_threshold": 4096, + "modal_full": 4096, + "adams_threshold": 512, + "adams_full": 4096, +} + + +@pytest.mark.parametrize("system", ["Linux", "Windows", "FreeBSD"]) +def test_the_table_off_macos_is_the_former_constants(system: str) -> None: + assert builtin(system).native() == LINUX + # Off macOS a spin never divides. + assert builtin(system, spin_milliseconds=1, width=4).native() == LINUX + + +def test_the_table_on_macos_divides_only_on_a_spinning_top_tier() -> None: + assert builtin("Darwin").native() == APPLE + assert builtin("Darwin", spin_milliseconds=0, width=6).native() == APPLE + assert ( + builtin("Darwin", spin_milliseconds=1, width=SPIN_MAX_TEAM).native() + == APPLE_SPINNING + ) + assert ( + builtin("Darwin", spin_milliseconds=1, width=SPIN_MAX_TEAM + 1).native() + == APPLE + ) + assert SPIN_DIVISOR == 16 + + +def test_a_users_fft_start_replaces_the_tables_undivided() -> None: + # SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES: the start as given, never divided, its + # full size the table's undivided one or the start where that is larger. + spinning = builtin("Darwin", spin_milliseconds=1, width=6, fft_threshold=1000) + assert (spinning.fft_threshold, spinning.fft_full) == (1000, 131072) + assert spinning.stage_threshold == APPLE_SPINNING["stage_threshold"] + large = builtin("Linux", fft_threshold=1 << 20) + assert (large.fft_threshold, large.fft_full) == (1 << 20, 1 << 20) + + +def test_the_process_record_is_its_platforms_table() -> None: + # The import's inputs: the system, the wait it chose, the runtime's width and the + # user's FFT start. + start = os.environ.get("SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES", "").strip() + expected = builtin( + platform.system(), + spin_milliseconds=SPIN_MILLISECONDS if native.WAIT_POLICY == "spin" else 0, + width=native.build_info().runtime_threads, + fft_threshold=int(start) if start else None, + ) + assert native.BUILTIN_EXECUTION == expected + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("stage_threshold", 0), + ("stage_full", 4095), + ("fft_threshold", -1), + ("medium_threads", 0), + ("compact_threads", True), + ("response_full_columns", 1.5), + ("stable_kernel_team", 1), + ], +) +def test_a_record_refuses_a_value_out_of_range(field: str, value: object) -> None: + with pytest.raises(ValueError, match=field.split("_")[0]): + builtin("Linux").updated({field: value}) + + +def test_records_round_trip_and_name_their_differences() -> None: + table = builtin("Linux") + assert ExecutionRecord.from_native(table.native()) == table + with pytest.raises(ValueError, match=r"missing \['stable_kernel_team'\]"): + ExecutionRecord.from_native( + {k: v for k, v in table.native().items() if k != "stable_kernel_team"} + ) + with pytest.raises(ValueError, match=r"unknown \['extra'\]"): + ExecutionRecord.from_native(table.native() | {"extra": 1}) + with pytest.raises(ValueError, match="unknown CPU execution settings"): + table.updated({"divisor": 2}) + changed = table.updated({"stage_threshold": 1024, "medium_threads": 6}) + assert changed.differences(table) == { + "stage_threshold": (1024, 4096), + "medium_threads": (6, 4), + } + assert table.differences(table) == {} + + +def _payload(case: PhaseACase) -> dict[str, object]: + problem, grid = case + backend = NativeBackend(problem, grid=grid) + try: + return _native_problem(problem, backend._terms) # pyright: ignore[reportPrivateUsage] + finally: + backend.close() + + +def test_a_state_reads_back_the_record_it_was_built_with( + phase_a_case: PhaseACase, +) -> None: + payload = _payload(phase_a_case) + assert payload["cpu_execution"] == native.BUILTIN_EXECUTION.native() + serial = dataclasses.replace( + native.BUILTIN_EXECUTION, + **{f"{name}_threshold": 1 << 40 for name in ("stage", "copy", "adams")}, + **{f"{name}_full": 1 << 40 for name in ("stage", "copy", "adams")}, + ) + first = _core.State(payload) + second = _core.State(payload | {"cpu_execution": serial.native()}) + try: + # Two live workspaces hold their own records (solprop SEP 46 section 7 item 7). + assert first.cpu_execution == native.BUILTIN_EXECUTION.native() + assert second.cpu_execution == serial.native() + if native.build_info().cpu_size_policy_enabled: + assert all( + choice["threads"] == 1 + for name, choice in second.execution_sizing.items() + if name.startswith(("kernels.stage.", "kernels.copy.")) + ) + finally: + first.close() + second.close() + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"cpu_execution": None}, "must be a dict"), + ({"cpu_execution": {"stage_threshold": 1}}, "has 24 fields, got 1"), + ("missing", "needs its CPU execution record"), + ], +) +def test_a_state_refuses_a_missing_or_malformed_record( + phase_a_case: PhaseACase, change: dict[str, object] | str, message: str +) -> None: + payload = _payload(phase_a_case) + if change == "missing": + del payload["cpu_execution"] + else: + assert isinstance(change, dict) + payload |= change + with pytest.raises(ValueError, match=message): + _core.State(payload) + + +def test_a_state_refuses_a_record_the_native_core_refuses( + phase_a_case: PhaseACase, +) -> None: + payload = _payload(phase_a_case) + record = native.BUILTIN_EXECUTION.native() + with pytest.raises(ValueError, match="positive threshold"): + _core.State(payload | {"cpu_execution": record | {"dense_full": 1}}) + with pytest.raises(ValueError, match="positive response bounds"): + _core.State(payload | {"cpu_execution": record | {"medium_threads": 0}}) + renamed = {k: v for k, v in record.items() if k != "adams_full"} + with pytest.raises(ValueError, match="misses a field: adams_full"): + _core.State(payload | {"cpu_execution": renamed | {"adams_fulll": 1}}) + + +def test_a_backend_given_a_record_builds_with_it_and_records_it( + phase_a_case: PhaseACase, +) -> None: + problem, grid = phase_a_case + given = native.BUILTIN_EXECUTION.updated({"medium_threads": 2}) + backend = NativeBackend(problem, grid=grid, cpu_execution=given) + default = NativeBackend(problem, grid=grid) + try: + calibration = backend.metadata()["calibration"] + assert isinstance(calibration, dict) + assert calibration["workspace_source"] == "given" + assert calibration["workspace"]["medium_threads"] == { + "applied": 2, + "built_in": native.BUILTIN_EXECUTION.medium_threads, + } + recorded = default.metadata()["calibration"] + assert isinstance(recorded, dict) + # The suite runs with SOLPROP_CALIBRATION=off: the built-in table. + assert recorded["profile"] == "built-in" + assert recorded["mode"] == "off" + finally: + backend.close() + default.close() diff --git a/native/tests/test_planning.py b/native/tests/test_planning.py new file mode 100644 index 000000000..c2a037140 --- /dev/null +++ b/native/tests/test_planning.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A workspace's FFT planning: the policy, a capture and a profile (solprop SEP 46 +section 6.7 and section 7 item 9).""" + +from __future__ import annotations + +from typing import cast + +import pytest +from conftest import PhaseACase + +import solprop_native as native +from solprop_native import _core +from solprop_native.backend import NativeBackend + +if native.build_info().device_backend != "none": # pragma: no cover - device builds + pytest.skip("device transforms keep no FFTW wisdom", allow_module_level=True) + +POLICY = {"rows": "measure", "threaded_rows": "estimate", "planes": "measure"} + + +def _plan(case: PhaseACase, planning: dict[str, object] | None) -> dict[str, object]: + """Build the case's workspace under `planning`; return what its planning found.""" + problem, grid = case + backend = NativeBackend(problem, grid=grid, fft_planning=planning) + try: + found = dict(backend._native_state.fft_planning) # pyright: ignore[reportPrivateUsage] + found["metadata"] = cast(dict[str, object], backend.metadata()["calibration"])[ + "fft_planning" + ] + return found + finally: + backend.close() + + +def _entries(wisdom: str | None) -> int: + """Return the plans a wisdom text holds, past its preamble.""" + return 0 if wisdom is None else max(0, len(wisdom.strip().splitlines()) - 2) + + +def _capture(case: PhaseACase) -> dict[str, str]: + found = _plan( + case, {"mode": "capture", "capture_effort": "measure", "time_limit": 1.0} + ) + return cast(dict[str, str], found["captured"]) + + +def test_without_a_planning_the_policy_plans(phase_a_case: PhaseACase) -> None: + found = _plan(phase_a_case, None) + assert found["groups"] == {} + assert found["metadata"] == "policy" + + +def test_a_capture_keeps_each_plans_wisdom_alone(phase_a_case: PhaseACase) -> None: + found = _plan( + phase_a_case, + {"mode": "capture", "capture_effort": "measure", "time_limit": 1.0}, + ) + captured = cast(dict[str, str], found["captured"]) + groups = cast(dict[str, str], found["groups"]) + assert captured + assert set(captured) <= set(groups) + assert set(groups.values()) <= {"rows", "threaded_rows", "planes"} + for key, text in captured.items(): + assert text.startswith("(fftw-3") + assert key.split(":")[-1].startswith("t") + # Nothing a capture learnt outlives its workspace. + assert _entries(_core.export_fftw_wisdom()) == 0 + # A capture at estimate keeps nothing. + estimated = _plan(phase_a_case, {"mode": "capture", "capture_effort": "estimate"}) + assert estimated["captured"] == {} + + +def test_a_profile_builds_its_plans_from_their_wisdom_and_forgets( + phase_a_case: PhaseACase, +) -> None: + wisdom = _capture(phase_a_case) + efforts = {"rows": "measure", "threaded_rows": "measure", "planes": "measure"} + found = _plan( + phase_a_case, {"mode": "profile", "efforts": efforts, "wisdom": wisdom} + ) + assert found["missing"] == [] + assert found["metadata"] == {"plans": len(found["groups"]), "missing": []} # type: ignore[arg-type] + assert _entries(_core.export_fftw_wisdom()) == 0 + + +def test_stale_wisdom_never_reaches_a_profiled_workspace( + phase_a_case: PhaseACase, +) -> None: + # Wisdom an earlier run or a file left in the process (SEP 46 section 7 item 9): + # a profile forgets it before planning, and its estimate plans are FFTW's own. + wisdom = _capture(phase_a_case) + for text in wisdom.values(): + assert _core.import_fftw_wisdom(text) + assert _entries(_core.export_fftw_wisdom()) > 0 + efforts = {"rows": "estimate", "threaded_rows": "estimate", "planes": "estimate"} + found = _plan(phase_a_case, {"mode": "profile", "efforts": efforts, "wisdom": {}}) + assert found["missing"] == [] + assert _entries(_core.export_fftw_wisdom()) == 0 + + +def test_two_workspaces_plan_the_same_in_either_order(phase_a_case: PhaseACase) -> None: + first = _plan(phase_a_case, {"mode": "capture", "capture_effort": "measure"}) + _plan(phase_a_case, {"mode": "profile", "efforts": POLICY, "wisdom": {}}) + second = _plan(phase_a_case, {"mode": "capture", "capture_effort": "measure"}) + assert first["groups"] == second["groups"] + assert set(cast(dict[str, str], first["captured"])) == set( + cast(dict[str, str], second["captured"]) + ) + + +def test_wisdom_that_no_longer_builds_a_plan_falls_back_and_is_recorded( + phase_a_case: PhaseACase, +) -> None: + wisdom = _capture(phase_a_case) + broken = dict.fromkeys(wisdom, "(not wisdom)") + found = _plan( + phase_a_case, + { + "mode": "profile", + "efforts": dict.fromkeys(POLICY, "measure"), + "wisdom": broken, + }, + ) + assert sorted(cast(list[str], found["missing"])) == sorted(broken) + # Another FFTW's or another machine's wisdom imports but builds no plan here. + foreign = { + key: "(fftw-3.3.11 fftw_wisdom #x3c273403 #x192df114 #x4d08727c #xe98e9b9d\n)\n" + for key in wisdom + } + found = _plan( + phase_a_case, + { + "mode": "profile", + "efforts": dict.fromkeys(POLICY, "measure"), + "wisdom": foreign, + }, + ) + assert sorted(cast(list[str], found["missing"])) == sorted(foreign) + + +@pytest.mark.parametrize( + ("planning", "message"), + [ + ({"mode": "sometimes"}, "legacy, profile or capture"), + ( + {"mode": "profile", "efforts": {"rows": "quickly"}}, + "estimate, measure or patient", + ), + ({"mode": "profile", "efforts": {"columns": "measure"}}, "rows, threaded_rows"), + ( + {"mode": "capture", "capture_effort": "exhaustive"}, + "estimate, measure or patient", + ), + ({"mode": 3}, "wrong types"), + ], +) +def test_a_malformed_planning_is_refused( + phase_a_case: PhaseACase, planning: dict[str, object], message: str +) -> None: + with pytest.raises(ValueError, match=message): + _plan(phase_a_case, planning) diff --git a/native/tests/test_profile.py b/native/tests/test_profile.py new file mode 100644 index 000000000..1d7b7fb03 --- /dev/null +++ b/native/tests/test_profile.py @@ -0,0 +1,501 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Topology, allocation classes, fingerprints and the profile a process applies +(solprop SEP 46 sections 5 and 6.4).""" + +from __future__ import annotations + +import dataclasses +import json +from collections.abc import Mapping +from pathlib import Path + +import pytest + +from solprop_native import _profile # pyright: ignore[reportPrivateUsage] +from solprop_native._allocation import AllocationClass, classify, match +from solprop_native._execution import builtin +from solprop_native._fingerprint import CALIBRATION_VERSION, build_identity, fingerprint +from solprop_native._profile import Process, choose +from solprop_native._store import Generation, Row, Store, StoreWarning +from solprop_native._topology import Topology, parse_cpu_list, read_topology + + +def _sysfs( + root: Path, + *, + sockets: int, + cores: int, + smt: bool, + l2_per: int = 1, + capacity: Mapping[int, int] | None = None, +) -> Path: + """Write a fake ``/sys/devices/system/cpu`` with `sockets` x `cores` cores.""" + base = root / "sys" / "devices" / "system" / "cpu" + threads = 2 if smt else 1 + total = sockets * cores + count = total * threads + base.mkdir(parents=True) + (base / "online").write_text(f"0-{count - 1}\n", encoding="utf-8") + for cpu in range(count): + core = cpu % total + package = core // cores + local = core % cores + siblings = [core + total * index for index in range(threads)] + directory = base / f"cpu{cpu}" + (directory / "topology").mkdir(parents=True) + (directory / "topology" / "physical_package_id").write_text(f"{package}\n") + (directory / "topology" / "core_id").write_text(f"{local}\n") + (directory / f"node{package}").mkdir() + first_l2 = package * cores + (local // l2_per) * l2_per + l2 = [ + c + total * index + for c in range(first_l2, first_l2 + l2_per) + for index in range(threads) + ] + l3 = [ + c + total * index + for c in range(package * cores, (package + 1) * cores) + for index in range(threads) + ] + for index, (level, members) in enumerate( + [("1", siblings), ("2", l2), ("3", l3)] + ): + cache = directory / "cache" / f"index{index}" + cache.mkdir(parents=True) + (cache / "level").write_text(level + "\n") + (cache / "type").write_text("Unified\n" if level != "1" else "Data\n") + (cache / "shared_cpu_list").write_text( + ",".join(str(c) for c in sorted(members)) + "\n" + ) + if capacity is not None: + (directory / "cpu_capacity").write_text(f"{capacity[core]}\n") + return base + + +def test_cpu_lists_parse_ranges_and_singletons() -> None: + assert parse_cpu_list("0-2,5,7-8\n") == (0, 1, 2, 5, 7, 8) + assert parse_cpu_list(",") == () + + +def test_a_two_socket_smt_machine_reads_its_caches_and_nodes(tmp_path: Path) -> None: + root = _sysfs(tmp_path, sockets=2, cores=4, smt=True) + topology = read_topology("Linux", root=root) + assert topology.cpus == tuple(range(16)) + assert len(set(topology.core.values())) == 8 + assert topology.core[0] == topology.core[8] + assert topology.socket[3] == 0 + assert topology.socket[4] == 1 + assert topology.numa[4] == 1 + assert topology.l3[1] == topology.l3[3] != topology.l3[4] + record = topology.record() + assert record["sockets"] == 2 + assert record["smt"] is True + assert record["kinds"] == [8] + # The whole machine; one socket's cores alone; across sockets without SMT. + whole = classify(topology, topology.cpus) + assert whole == AllocationClass( + cores=8, kinds=(8,), l2_groups=0, l3_groups=2, numa_nodes=2, sockets=2, smt=True + ) + one = classify(topology, [0, 1, 2, 3]) + assert (one.cores, one.sockets, one.smt) == (4, 1, False) + spread = classify(topology, [0, 1, 4, 5]) + assert (spread.cores, spread.sockets, spread.numa_nodes) == (4, 2, 2) + assert one != spread + assert one.key() != spread.key() + assert classify(topology, [], quota=2).quota == 2 + assert classify(topology, [0, 1], quota=4).quota is None + + +def test_shared_l2_groups_and_capacity_kinds_are_read(tmp_path: Path) -> None: + capacity = {core: 1024 if core < 2 else 512 for core in range(6)} + root = _sysfs(tmp_path, sockets=1, cores=6, smt=False, l2_per=2, capacity=capacity) + topology = read_topology("Linux", root=root) + assert topology.kind[0] == 0 + assert topology.kind[5] == 1 + allocation = classify(topology, topology.cpus) + assert allocation.kinds == (2, 4) + assert allocation.l2_groups == 3 + assert classify(topology, [0, 1]).l2_groups == 1 + + +def test_an_intel_hybrid_reads_its_kinds_from_the_pmus(tmp_path: Path) -> None: + root = _sysfs(tmp_path, sockets=1, cores=4, smt=False) + devices = root.parent.parent + (devices / "cpu_core").mkdir() + (devices / "cpu_core" / "cpus").write_text("0-1\n") + (devices / "cpu_atom").mkdir() + (devices / "cpu_atom" / "cpus").write_text("2-3\n") + topology = read_topology("Linux", root=root) + assert classify(topology, [0, 1, 2, 3]).kinds == (2, 2) + # Two cores of the fast kind and two of the slow are different classes: every + # kind the machine has is counted, held or not, and the span says which are held. + fast, slow = classify(topology, [0, 1]), classify(topology, [2, 3]) + assert (fast.kinds, slow.kinds) == ((2, 0), (0, 2)) + assert fast.key() != slow.key() + assert fast.span() != slow.span() + assert match(fast, [(slow, None)]) is None + + +def test_macos_numbers_its_cores_by_kind() -> None: + values = { + "hw.nperflevels": 2, + "hw.perflevel0.physicalcpu": 6, + "hw.perflevel0.cpusperl2": 6, + "hw.perflevel1.physicalcpu": 12, + "hw.perflevel1.cpusperl2": 6, + } + topology = read_topology("Darwin", sysctl=values.get) + assert topology.cpus == tuple(range(18)) + assert [topology.kind[cpu] for cpu in (0, 5, 6, 17)] == [0, 0, 1, 1] + allocation = classify(topology, ()) + assert allocation.kinds == (6, 12) + assert allocation.l2_groups == 3 + + +def test_unreadable_sources_give_a_flat_machine(tmp_path: Path) -> None: + flat = read_topology("Linux", root=tmp_path / "missing") + assert len(set(flat.core.values())) == len(flat.cpus) + assert read_topology("Darwin", sysctl=lambda name: None).cpus == flat.cpus + assert read_topology("Plan9").cpus == flat.cpus + + +def _cls(cores: int, sockets: int = 1, smt: bool = False) -> AllocationClass: + return AllocationClass( + cores=cores, + kinds=(cores,), + l2_groups=0, + l3_groups=sockets, + numa_nodes=sockets, + sockets=sockets, + smt=smt, + ) + + +def test_rows_match_exactly_or_by_the_nearest_smaller_class() -> None: + rows = [(_cls(4), "four"), (_cls(8), "eight"), (_cls(16, sockets=2), "two")] + exact = match(_cls(8), rows) + assert exact is not None + assert (exact.row, exact.exact, exact.scope) == ("eight", True, "all") + nearer = match(_cls(12), rows) + assert nearer is not None + assert (nearer.row, nearer.exact, nearer.scope) == ("eight", False, "workspace") + # A class of another span structure, or smaller than every row, has no row. + assert match(_cls(12, sockets=2), rows) is None + assert match(_cls(2), rows) is None + assert match(_cls(8, smt=True), rows) is None + # A later row of an equal class replaces an earlier one. + later = match(_cls(8), [*rows, (_cls(8), "upgraded")]) + assert later is not None + assert later.row == "upgraded" + + +def test_allocation_classes_round_trip_and_refuse_malformed_records() -> None: + cls = _cls(8) + assert AllocationClass.read(cls.record()) == cls + for broken in ( + {}, + cls.record() | {"kinds": "8"}, + cls.record() | {"cores": 8.0}, + cls.record() | {"smt": 0}, + cls.record() | {"quota": "2"}, + ): + with pytest.raises(ValueError, match="not an allocation class"): + AllocationClass.read(broken) + + +def _package(tmp_path: Path, extension: bytes = b"binary") -> Path: + package = tmp_path / "solprop_native" + package.mkdir() + (package / "_core.abi3.so").write_bytes(extension) + (package / "_build.json").write_text( + json.dumps({"compiler_id": "GNU", "openmp_libraries": "gomp,pthread"}) + ) + return package + + +def test_the_fingerprint_digests_machine_build_and_version(tmp_path: Path) -> None: + package = _package(tmp_path) + identity = build_identity([package]) + assert identity["record"] == { + "compiler_id": "GNU", + "openmp_libraries": "gomp,pthread", + } + assert isinstance(identity["extension"], str) + topology = read_topology( + "Linux", root=_sysfs(tmp_path, sockets=1, cores=2, smt=False) + ) + first = fingerprint(topology, identity, "Linux", "x86_64") + assert first == fingerprint(topology, identity, "Linux", "x86_64") + assert first.calibration == CALIBRATION_VERSION + assert first.record()["digest"] == first.digest + # A rebuilt extension, another system or another machine is another fingerprint. + (package / "_core.abi3.so").write_bytes(b"rebuilt") + assert fingerprint(topology, build_identity([package]), "Linux", "x86_64") != first + assert fingerprint(topology, identity, "Windows", "x86_64") != first + other = read_topology( + "Linux", root=_sysfs(tmp_path / "b", sockets=1, cores=4, smt=False) + ) + assert fingerprint(other, identity, "Linux", "x86_64").digest != first.digest + # No build record or extension: still a fingerprint, of what is there. + empty = tmp_path / "empty" + empty.mkdir() + assert build_identity([empty])["record"] is None + assert build_identity([empty])["extension"] is None + + +def _machine(tmp_path: Path) -> Topology: + return read_topology("Linux", root=_sysfs(tmp_path, sockets=1, cores=4, smt=False)) + + +def _publish( + tmp_path: Path, topology: Topology, package: Path, rows: tuple[Row, ...] +) -> Store: + store = Store(tmp_path / "store") + digest = fingerprint(topology, build_identity([package])).digest + store.publish( + Generation(fingerprint=digest, name="g1", created="2026-11-08", rows=rows) + ) + return store + + +def test_no_profile_or_off_leaves_the_built_in_table(tmp_path: Path) -> None: + package = _package(tmp_path) + topology = _machine(tmp_path) + off = choose({"SOLPROP_CALIBRATION": "off"}, packages=[package]) + assert off.fingerprint is None + assert off.threads() is None + assert off.wait() is None + table = builtin("Linux") + process = Process(threads=4, wait="spin") + assert off.workspace(process, table) == (table, "SOLPROP_CALIBRATION=off") + empty = choose( + {}, packages=[package], topology=topology, searched=[Store(tmp_path)] + ) + assert empty.generation is None + record = empty.record(process, table, table, "no profile") + assert record["profile"] == "built-in" + assert record["mode"] == "auto" + assert record["workspace"]["stage_threshold"] == { # type: ignore[index] + "applied": 4096, + "built_in": 4096, + } + + +def test_an_exact_row_supplies_process_and_workspace_settings(tmp_path: Path) -> None: + package = _package(tmp_path) + topology = _machine(tmp_path) + row = Row( + allocation=classify(topology, topology.cpus).record(), + level="standard", + process={"threads": 3, "wait": "passive"}, + workspace={"stage_threshold": 1024, "stage_full": 2048}, + fitted_under={"threads": 3, "wait": "passive"}, + ) + store = _publish(tmp_path, topology, package, (row,)) + chosen = choose( + {}, packages=[package], cpus=topology.cpus, topology=topology, searched=[store] + ) + assert chosen.threads() == 3 + assert chosen.wait() == "passive" + table = builtin("Linux") + applied, reason = chosen.workspace(Process(3, "passive"), table) + assert reason == "profile" + assert (applied.stage_threshold, applied.stage_full) == (1024, 2048) + # Under other process settings the workspace settings do not hold. + assert chosen.workspace(Process(3, "spin"), table)[0] == table + assert chosen.workspace(Process(4, "passive"), table)[0] == table + assert ( + chosen.workspace(Process(3, "environment: OMP_WAIT_POLICY=passive"), table)[0] + == table + ) + record = chosen.record(Process(3, "passive"), applied, table, reason) + profile = record["profile"] + assert isinstance(profile, dict) + assert profile["level"] == "standard" + assert profile["exact"] is True + assert record["workspace"]["stage_threshold"] == { # type: ignore[index] + "applied": 1024, + "built_in": 4096, + } + + +def test_a_nearer_row_supplies_workspace_settings_only(tmp_path: Path) -> None: + package = _package(tmp_path) + topology = _machine(tmp_path) + row = Row( + allocation=classify(topology, [0, 1]).record(), + level="standard", + process={"threads": 2, "wait": "spin"}, + workspace={"medium_threads": 2}, + fitted_under={"threads": 2, "wait": "spin"}, + ) + store = _publish(tmp_path, topology, package, (row,)) + chosen = choose( + {}, packages=[package], cpus=topology.cpus, topology=topology, searched=[store] + ) + assert chosen.found is not None + assert not chosen.found.exact + assert chosen.threads() is None + assert chosen.wait() is None + applied, reason = chosen.workspace(Process(4, "spin"), builtin("Linux")) + assert (applied.medium_threads, reason) == (2, "profile") + # A row for another class only: none applies. + unrelated = choose( + {}, packages=[package], cpus=[0], topology=topology, searched=[store] + ) + assert unrelated.found is None + assert unrelated.note == "no row for this allocation" + + +def test_malformed_rows_warn_and_leave_the_table(tmp_path: Path) -> None: + package = _package(tmp_path) + topology = _machine(tmp_path) + allocation = classify(topology, topology.cpus).record() + rows = ( + Row(allocation={"cores": "many"}, level="quick"), + Row( + allocation=allocation, + level="quick", + workspace={"stage_threshold": 0}, + fitted_under={"wait": "spin"}, + ), + ) + store = _publish(tmp_path, topology, package, rows) + _profile._WARNED.clear() # pyright: ignore[reportPrivateUsage] + with pytest.warns(StoreWarning, match="ignoring a calibration row"): + chosen = choose( + {}, + packages=[package], + cpus=topology.cpus, + topology=topology, + searched=[store], + ) + table = builtin("Linux") + with pytest.warns(StoreWarning, match="workspace settings"): + assert chosen.workspace(Process(4, "spin"), table)[0] == table + empty = Row(allocation=allocation, level="quick") + assert ( + "no workspace" + in choose( + {}, + packages=[package], + cpus=topology.cpus, + topology=topology, + searched=[_publish(tmp_path / "x", topology, package, (empty,))], + ).workspace(Process(4, "spin"), table)[1] + ) + + +def test_an_unreadable_store_warns_and_leaves_the_table( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = _package(tmp_path) + + def broken(*args: object, **kwargs: object) -> None: + raise PermissionError("denied") + + monkeypatch.setattr(_profile, "lookup", broken) + _profile._WARNED.clear() # pyright: ignore[reportPrivateUsage] + with pytest.warns(StoreWarning, match="denied"): + chosen = choose({}, packages=[package], topology=_machine(tmp_path)) + assert chosen.generation is None + assert "unreadable" in chosen.note + + +def test_a_rows_fft_planning_applies_where_its_workspace_settings_do( + tmp_path: Path, +) -> None: + package = _package(tmp_path) + topology = _machine(tmp_path) + allocation = classify(topology, topology.cpus).record() + efforts = {"rows": "measure", "threaded_rows": "measure", "planes": "measure"} + row = Row( + allocation=allocation, + level="standard", + fitted_under={"threads": 4, "wait": "spin"}, + planning={"efforts": efforts}, + ) + store = Store(tmp_path / "store") + digest = fingerprint(topology, build_identity([package])).digest + store.publish( + Generation( + fingerprint=digest, + name="g1", + created="2026-11-08", + rows=(row,), + wisdom={"r2c:8:1:8:5:out:0:0:t1": "(wisdom)"}, + ) + ) + chosen = choose( + {}, packages=[package], cpus=topology.cpus, topology=topology, searched=[store] + ) + applied = chosen.planning(Process(4, "spin"), {}) + assert applied == { + "mode": "profile", + "efforts": efforts, + "wisdom": {"r2c:8:1:8:5:out:0:0:t1": "(wisdom)"}, + } + # The user's own planning wins; other process settings keep the policy. + assert chosen.planning(Process(4, "spin"), {"SOLPROP_FFTW_PLAN": "patient"}) is None + assert chosen.planning(Process(4, "spin"), {"SOLPROP_FFTW_WISDOM": "w"}) is None + assert chosen.planning(Process(4, "passive"), {}) is None + assert chosen.planning(Process(2, "spin"), {}) is None + # A row without planning, or with malformed efforts, keeps the policy too. + bare = dataclasses.replace(row, planning={}) + malformed = dataclasses.replace(row, planning={"efforts": "measure"}) + for other in (bare, malformed): + store.publish( + Generation(fingerprint=digest, name="g2", created="t", rows=(other,)) + ) + again = choose( + {}, + packages=[package], + cpus=topology.cpus, + topology=topology, + searched=[store], + ) + assert again.planning(Process(4, "spin"), {}) is None + assert ( + choose({"SOLPROP_CALIBRATION": "off"}, packages=[package]).planning( + Process(4, "spin"), {} + ) + is None + ) + + +def test_an_execution_descriptor_changes_with_any_part_of_the_execution() -> None: + from solprop_native._profile import descriptor + + def key( + shape: tuple[int, ...] = (1, 1, 1, 4096), + grid_kind: str = "envelope", + integrator: str = "RungeKutta", + sizing: Mapping[str, object] | None = None, + process: Process | None = None, + fingerprint: str = "f", + ) -> str: + return descriptor( + shape=shape, + grid_kind=grid_kind, + integrator=integrator, + sizing={"kernels.stage.4096": {"threads": 4}} if sizing is None else sizing, + process=Process(4, "spin") if process is None else process, + fingerprint=fingerprint, + ) + + base = key() + assert len(base) == 24 + # The widths chosen are not part of it; the operations and their work are. + assert key(sizing={"kernels.stage.4096": {"threads": 1}}) == base + for changed in ( + key(shape=(2, 1, 1, 4096)), + key(grid_kind="field"), + key(integrator="Multistep"), + key(sizing={"kernels.stage.8192": {}}), + key(process=Process(2, "spin")), + key(fingerprint="g"), + ): + assert changed != base diff --git a/native/tests/test_profile_import.py b/native/tests/test_profile_import.py new file mode 100644 index 000000000..86609952c --- /dev/null +++ b/native/tests/test_profile_import.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A published profile applies at native import, and runs record it (solprop SEP 46 +section 7 items 7 and 8). Each case runs in a fresh interpreter, since process +settings are fixed when the runtime starts.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +from solprop_native._store import Generation, Row, Store + +REPORT = """ +import json, solprop_native as n +from solprop_native.backend import NativeBackend +from solprop.grid import EnvelopeGrid +from solprop.nonlinear.gnlse import problem_from +from solprop.pulses import SechPulse +from solprop.stepper import PIController +from solprop.translate.problem import assemble +grid = EnvelopeGrid.from_limits(1.0, 835e-9, (500e-9, 2e-6), 1e-12) +model = problem_from([0.0, 0.0, -11.83e-27], omega_ref=grid.omega0, gamma=0.11, + shock=False) +field = SechPulse(wavelength=835e-9, width=100e-15, peak_power=1e4).build(grid) +problem = assemble(grid, model.mode, [model.kerr], field, save_planes=[1e-3], + density=1.0, shock=False, prefactor_index=1.0, + controller=PIController(rtol=1e-8, atol=1e-16), dz_max=1e-3, + dz_init=1e-4) +backend = NativeBackend(problem, grid=grid) +calibration = backend.metadata()["calibration"] +backend.close() +print(json.dumps({ + "fingerprint": n.PROFILE.fingerprint and n.PROFILE.fingerprint.digest, + "build": n.PROFILE.fingerprint and n.PROFILE.fingerprint.build, + "allocation": n.PROFILE.allocation and n.PROFILE.allocation.record(), + "threads": n.build_info().runtime_threads, + "wait": n.WAIT_POLICY, + "stage": n.EXECUTION.stage_threshold, + "source": n.EXECUTION_SOURCE, + "calibration": calibration, +})) +""" + + +def _report(store: Path, **environ: str) -> dict[str, Any]: + clean = { + name: value + for name, value in os.environ.items() + if name + not in { + "SOLPROP_CALIBRATION", + "SOLPROP_THREADS", + "SOLPROP_NATIVE_THREADS", + "OMP_WAIT_POLICY", + "GOMP_SPINCOUNT", + "KMP_BLOCKTIME", + "SOLPROP_NATIVE_WAIT", + "SOLPROP_CALIBRATION_PATH", + } + } + completed = subprocess.run( + [sys.executable, "-c", REPORT], + capture_output=True, + text=True, + check=False, + env=clean | {"SOLPROP_CACHE_DIR": str(store)} | environ, + ) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +@pytest.fixture(scope="module") +def baseline(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: + """The process with an empty store: the built-in table.""" + return _report(tmp_path_factory.mktemp("empty")) + + +def _publish(store: Path, baseline: dict[str, Any], row: Row) -> None: + fingerprint = baseline["fingerprint"] + assert isinstance(fingerprint, str) + Store(store).publish( + Generation( + fingerprint=fingerprint, name="g1", created="2026-11-08", rows=(row,) + ) + ) + + +def test_without_a_profile_the_process_runs_the_built_in_table( + baseline: dict[str, Any], +) -> None: + assert baseline["source"] == "no profile for this machine and build" + # The fingerprint holds the build: its record and the extension's digest. + build = baseline["build"] + assert isinstance(build, dict) + assert build["extension"] is not None + assert isinstance(build["record"], dict) + calibration = baseline["calibration"] + assert isinstance(calibration, dict) + assert calibration["profile"] == "built-in" + + +def test_an_exact_row_sets_the_pool_the_wait_and_the_workspace( + tmp_path: Path, baseline: dict[str, Any] +) -> None: + allocation = cast(dict[str, object], baseline["allocation"]) + # The row's width within the process's budget, which a small runner holds to one; + # its workspace settings were fitted under the width the process then runs. + width = min(2, baseline["threads"]) + row = Row( + allocation=allocation, + level="standard", + process={"threads": 2, "wait": "passive"}, + workspace={"stage_threshold": 1234, "stage_full": 1234}, + fitted_under={"threads": width, "wait": "passive"}, + ) + _publish(tmp_path, baseline, row) + applied = _report(tmp_path) + assert (applied["threads"], applied["wait"]) == (width, "passive") + assert (applied["stage"], applied["source"]) == (1234, "profile") + calibration = cast(dict[str, Any], applied["calibration"]) + profile = cast(dict[str, Any], calibration["profile"]) + assert profile["generation"] == "g1" + assert profile["exact"] is True + # Read back from the workspace, beside the built-in value. + assert calibration["workspace"]["stage_threshold"]["applied"] == 1234 + assert calibration["process"] == {"threads": width, "wait": "passive"} + + # SOLPROP_CALIBRATION=off: the table, whatever the store holds. + off = _report(tmp_path, SOLPROP_CALIBRATION="off") + assert off["threads"] == baseline["threads"] + assert off["stage"] == baseline["stage"] + + # The user's thread count wins, and settings fitted on more threads give way; on a + # one-CPU runner the row was fitted on the one thread the user asks for. + narrowed = _report(tmp_path, SOLPROP_THREADS="1") + assert narrowed["threads"] == 1 + if width > 1: + assert narrowed["stage"] == baseline["stage"] + assert "fitted on 2 threads" in str(narrowed["source"]) + else: + assert narrowed["stage"] == 1234 + + # The user's wait wins, and settings fitted under passive waiting give way. + spinning = _report(tmp_path, OMP_WAIT_POLICY="active") + assert spinning["wait"] == "environment: OMP_WAIT_POLICY=active" + assert spinning["stage"] == baseline["stage"] + + +def test_a_shared_read_only_directory_supplies_a_profile( + tmp_path: Path, baseline: dict[str, Any] +) -> None: + allocation = cast(dict[str, object], baseline["allocation"]) + shared = tmp_path / "shared" + _publish( + shared, + baseline, + Row( + allocation=allocation, + level="thorough", + workspace={"medium_threads": 3}, + fitted_under={"wait": baseline["wait"]}, + ), + ) + applied = _report(tmp_path / "mine", SOLPROP_CALIBRATION_PATH=str(shared)) + assert applied["source"] == "profile" + calibration = applied["calibration"] + assert isinstance(calibration, dict) + assert calibration["workspace"]["medium_threads"]["applied"] == 3 diff --git a/native/tests/test_store.py b/native/tests/test_store.py new file mode 100644 index 000000000..bd03d4ad4 --- /dev/null +++ b/native/tests/test_store.py @@ -0,0 +1,357 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The per-user calibration store (solprop SEP 46 sections 5 and 6.8).""" + +from __future__ import annotations + +import json +import multiprocessing +import multiprocessing.synchronize +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from solprop_native import _store +from solprop_native._store import ( + STALE_CLAIM_SECONDS, + Generation, + Row, + Store, + StoreWarning, + cache_directory, + lookup, + mode, + search_path, +) + +ROW = Row( + allocation={"cores": 4}, + level="standard", + process={"threads": 4, "wait": "spin"}, + workspace={"stage_threshold": 2048}, + fitted_under={"threads": 4, "wait": "spin"}, + evidence={"geomean": 0.93}, +) + + +def _generation(name: str = "g1", fingerprint: str = "abc") -> Generation: + return Generation( + fingerprint=fingerprint, + name=name, + created="2026-11-08T12:00:00Z", + rows=(ROW,), + wisdom={"rows:c2c:4096": "(fftw-3.3.11 fftw_wisdom)"}, + ) + + +@pytest.mark.parametrize( + ("system", "environ", "expected"), + [ + ("Linux", {"HOME": "/home/u"}, "/home/u/.cache/solprop"), + ("Linux", {"HOME": "/home/u", "XDG_CACHE_HOME": "/x"}, "/x/solprop"), + ("Darwin", {"HOME": "/Users/u"}, "/Users/u/Library/Caches/solprop"), + ("Windows", {"HOME": "/h", "LOCALAPPDATA": "/L"}, "/L/solprop/Cache"), + ("Windows", {"HOME": "/h"}, "/h/AppData/Local/solprop/Cache"), + ("Linux", {"HOME": "/h", "SOLPROP_CACHE_DIR": "/c"}, "/c"), + ("Darwin", {"HOME": "/h", "SOLPROP_CACHE_DIR": "/c"}, "/c"), + ], +) +def test_the_store_lives_in_the_platforms_cache_directory( + system: str, environ: dict[str, str], expected: str +) -> None: + assert cache_directory(environ, system) == Path(expected) + + +def test_the_mode_and_search_path_are_read_from_the_environment() -> None: + assert mode({}) == "auto" + assert mode({"SOLPROP_CALIBRATION": " Use "}) == "use" + assert mode({"SOLPROP_CALIBRATION": "off"}) == "off" + with pytest.raises(ValueError, match="auto, use, off"): + mode({"SOLPROP_CALIBRATION": "always"}) + joined = os.pathsep.join(["/a", "", "/b"]) + assert search_path({"SOLPROP_CALIBRATION_PATH": joined}) == (Path("/a"), Path("/b")) + assert search_path({}) == () + + +def test_a_published_generation_is_read_back_whole(tmp_path: Path) -> None: + store = Store(tmp_path) + assert store.current("abc") is None + path = store.publish(_generation()) + read = store.current("abc") + assert read is not None + assert read.source == path + assert read.rows == (ROW,) + assert read.wisdom == {"rows:c2c:4096": "(fftw-3.3.11 fftw_wisdom)"} + # A later generation replaces it; the earlier file stays until cleared. + store.publish(_generation("g2")) + newer = store.current("abc") + assert newer is not None + assert newer.name == "g2" + assert path.exists() + + +def test_a_failed_publication_leaves_the_previous_generation_current( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store = Store(tmp_path) + store.publish(_generation()) + original = Path.replace + + def refuse(self: Path, target: Path) -> Path: + if Path(target).name == "current": + raise OSError("disk full") + return original(self, target) + + monkeypatch.setattr(Path, "replace", refuse) + with pytest.raises(OSError, match="disk full"): + store.publish(_generation("g2")) + monkeypatch.undo() + current = store.current("abc") + assert current is not None + assert current.name == "g1" + # No temporary file is left behind. + assert not [p for p in store.directory("abc").iterdir() if p.name.startswith(".")] + + +@pytest.mark.parametrize( + ("contents", "message"), + [ + ("{not json", "ignoring the calibration profile"), + (json.dumps({"format": 99}), "generation format 99"), + ( + json.dumps(_generation().record() | {"fingerprint": "other"}), + "names another machine", + ), + ( + json.dumps(_generation().record() | {"rows": [{"level": "never"}]}), + "a row's level", + ), + ], +) +def test_a_corrupt_or_foreign_generation_is_ignored_with_a_warning( + tmp_path: Path, contents: str, message: str +) -> None: + store = Store(tmp_path) + store.publish(_generation()) + (store.directory("abc") / "g1.json").write_text(contents, encoding="utf-8") + _store._WARNED.clear() # pyright: ignore[reportPrivateUsage] + with pytest.warns(StoreWarning, match=message): + assert store.current("abc") is None + + +def test_a_current_naming_a_missing_generation_is_ignored(tmp_path: Path) -> None: + store = Store(tmp_path) + store.directory("abc").mkdir(parents=True) + (store.directory("abc") / "current").write_text("gone\n", encoding="utf-8") + _store._WARNED.clear() # pyright: ignore[reportPrivateUsage] + with pytest.warns(StoreWarning, match="gone.json"): + assert store.current("abc") is None + + +@pytest.mark.parametrize( + "data", + [ + [], + {"format": 1, "fingerprint": 1, "name": "g", "created": "t", "rows": []}, + {"format": 1, "fingerprint": "f", "name": "g", "created": "t", "rows": {}}, + { + "format": 1, + "fingerprint": "f", + "name": "g", + "created": "t", + "rows": [], + "wisdom": {"k": 1}, + }, + {1: 2}, + ], +) +def test_a_malformed_generation_is_refused(data: object) -> None: + with pytest.raises(ValueError, match=r"generation|mapping|wisdom|rows"): + Generation.read(data) + + +def test_clearing_removes_generations_but_not_a_live_claim(tmp_path: Path) -> None: + store = Store(tmp_path) + store.publish(_generation()) + store.publish(_generation("g2", fingerprint="def")) + claim = store.claim("abc", "cls", time.time() + 60) + assert claim is not None + assert store.clear("abc") == 2 + assert store.current("abc") is None + assert store.current("def") is not None + assert claim.path.exists() + assert store.clear() == 2 + assert store.current("def") is None + + +def test_clearing_forgets_the_machines_refinements(tmp_path: Path) -> None: + # A refinement fitted over the built-in table would otherwise still apply. + store = Store(tmp_path) + store.publish(_generation()) + store.publish_refinement("abc", "d1", {"base": {}, "record": {}}) + store.publish_refinement("def", "d2", {"base": {}, "record": {}}) + assert store.clear("abc") == 3 # its current, its generation and its refinement + assert store.refinement("abc", "d1") is None + assert store.refinement("def", "d2") is not None + assert store.clear() == 1 + assert store.refinement("def", "d2") is None + + +def test_a_claim_is_exclusive_until_released(tmp_path: Path) -> None: + store = Store(tmp_path) + first = store.claim("abc", "cls", time.time() + 60) + assert first is not None + assert store.claim("abc", "cls", time.time() + 60) is None + # Another allocation class is a separate claim. + other = store.claim("abc", "other", time.time() + 60) + assert other is not None + holder = store.holder("abc", "cls") + assert holder is not None + assert (holder.host, holder.pid) == (socket.gethostname(), os.getpid()) + first.release() + assert store.holder("abc", "cls") is None + assert store.claim("abc", "cls", time.time() + 60) is not None + other.release() + + +def test_a_claim_past_its_deadline_or_whose_owner_died_is_stale( + tmp_path: Path, +) -> None: + store = Store(tmp_path) + path = store.directory("abc") / "claim-cls" + store.directory("abc").mkdir(parents=True) + overdue = time.time() - STALE_CLAIM_SECONDS - 1 + path.write_text( + json.dumps({"host": "elsewhere", "pid": 1, "deadline": overdue}), + encoding="utf-8", + ) + assert store.claim("abc", "cls", time.time() + 60) is not None + # A process that has exited on this host holds nothing. + gone = subprocess.run( + [sys.executable, "-c", "import os; print(os.getpid())"], + capture_output=True, + text=True, + check=True, + ) + path.write_text( + json.dumps( + { + "host": socket.gethostname(), + "pid": int(gone.stdout), + "deadline": time.time() + 600, + } + ), + encoding="utf-8", + ) + assert store.holder("abc", "cls") is None + # Another host's live claim is respected until its deadline. + path.write_text( + json.dumps({"host": "elsewhere", "pid": 1, "deadline": time.time() + 600}), + encoding="utf-8", + ) + assert store.claim("abc", "cls", time.time() + 60) is None + # A claim still being written is fresh; an old unreadable one is stale. + path.write_text("", encoding="utf-8") + assert store.claim("abc", "cls", time.time() + 60) is None + old = time.time() - 2 * STALE_CLAIM_SECONDS + os.utime(path, (old, old)) + assert store.claim("abc", "cls", time.time() + 60) is not None + + +def _race( + root: str, + start: multiprocessing.synchronize.Event, + results: multiprocessing.Queue[bool], + done: multiprocessing.synchronize.Event, +) -> None: + start.wait(60) + results.put(Store(Path(root)).claim("abc", "cls", time.time() + 60) is not None) + # Hold on until every process has tried: a winner that exits leaves a stale claim. + done.wait(60) + + +def test_concurrent_claims_admit_exactly_one(tmp_path: Path) -> None: + context = multiprocessing.get_context("spawn") + start, done = context.Event(), context.Event() + results: multiprocessing.Queue[bool] = context.Queue() + workers = [ + context.Process(target=_race, args=(str(tmp_path), start, results, done)) + for _ in range(6) + ] + for worker in workers: + worker.start() + start.set() + won = [results.get(timeout=60) for _ in workers] + done.set() + for worker in workers: + worker.join(timeout=60) + assert won.count(True) == 1 + + +def test_inconclusive_attempts_are_remembered(tmp_path: Path) -> None: + store = Store(tmp_path) + assert store.inconclusive_since("abc", "cls") is None + store.record_inconclusive("abc", "cls", when=1000.0) + store.record_inconclusive("abc", "other", when=2000.0) + assert store.inconclusive_since("abc", "cls") == 1000.0 + assert store.inconclusive_since("abc", "other") == 2000.0 + (store.directory("abc") / "attempts.json").write_text("[]", encoding="utf-8") + assert store.inconclusive_since("abc", "cls") is None + store.record_inconclusive("abc", "cls", when=3000.0) + assert store.inconclusive_since("abc", "cls") == 3000.0 + + +def test_lookup_reads_the_users_store_then_read_only_directories( + tmp_path: Path, +) -> None: + mine, shared = Store(tmp_path / "mine"), Store(tmp_path / "shared") + shared.publish(_generation("shared")) + found = lookup("abc", [mine, shared]) + assert found is not None + assert found.name == "shared" + mine.publish(_generation("mine")) + found = lookup("abc", [mine, shared]) + assert found is not None + assert found.name == "mine" + assert lookup("missing", [mine, shared]) is None + environ = { + "SOLPROP_CACHE_DIR": str(tmp_path / "mine"), + "SOLPROP_CALIBRATION_PATH": str(tmp_path / "shared"), + } + assert [store.root for store in _store.stores(environ)] == [ + tmp_path / "mine", + tmp_path / "shared", + ] + + +def test_generation_names_are_unique_and_sortable() -> None: + names = {_store.generation_name() for _ in range(20)} + assert len(names) == 20 + assert all(name.startswith("g20") for name in names) + + +def test_refinements_are_kept_by_descriptor(tmp_path: Path) -> None: + mine, shared = Store(tmp_path / "mine"), Store(tmp_path / "shared") + assert mine.refinement("abc", "d1") is None + path = shared.publish_refinement("abc", "d1", {"workspace": {"medium_threads": 2}}) + assert path.name == "d1.json" + found = _store.refinement("abc", "d1", [mine, shared]) + assert found is not None + assert found["workspace"] == {"medium_threads": 2} + assert found["descriptor"] == "d1" + assert _store.refinement("abc", "d2", [mine, shared]) is None + # A file naming another descriptor, or unreadable, is skipped with a warning. + path.write_text(json.dumps({"descriptor": "other"}), encoding="utf-8") + _store._WARNED.clear() # pyright: ignore[reportPrivateUsage] + with pytest.warns(StoreWarning, match="another descriptor"): + assert shared.refinement("abc", "d1") is None + path.write_text("{", encoding="utf-8") + with pytest.warns(StoreWarning, match="ignoring the refinement"): + assert shared.refinement("abc", "d1") is None diff --git a/src/solprop/__init__.py b/src/solprop/__init__.py index 77068dfb3..1b729eb9c 100644 --- a/src/solprop/__init__.py +++ b/src/solprop/__init__.py @@ -37,6 +37,7 @@ "adiabatic", "backends", "boundaries", + "calibration", "capabilities", "cli", "constants", diff --git a/src/solprop/backends/registry.py b/src/solprop/backends/registry.py index aba3b547f..bba91ffc7 100644 --- a/src/solprop/backends/registry.py +++ b/src/solprop/backends/registry.py @@ -481,6 +481,13 @@ def select( ['mlx', 'native', 'numpy_ref'] """ name = resolve_name(name) + if name == "native": + # SEP 46: a first native workspace in an allocation with no profile row runs + # the quick calibration first, before the extension's runtime starts if it + # has not yet. + from solprop.calibration.first_use import ensure # noqa: PLC0415 - lazy + + ensure() _check_required_device(name) if _BENCHMARK_GATED.get() and name != "numpy_ref": raise BackendError("the gated plasma benchmark requires numpy_ref") diff --git a/src/solprop/calibration/__init__.py b/src/solprop/calibration/__init__.py new file mode 100644 index 000000000..2d786d83a --- /dev/null +++ b/src/solprop/calibration/__init__.py @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Calibrating the native backend to its machine (SEP 46). + +The native backend's CPU execution settings (how each operation threads, how idle +threads wait, how wide the team is) are fitted to the machine by a bounded, one-off +measurement, kept in a per-user store in the platform's cache directory and applied +by every later run, which records the settings it used in its metadata +(``calibration``). Without a profile the native backend runs its built-in table. + +This package reads, shows, exports, imports and clears profiles without loading the +native extension. The manual page is ``docs/guide/performance/calibration.md``. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from solprop import _process +from solprop.calibration import _native +from solprop.exceptions import CalibrationError + +if TYPE_CHECKING: + from solprop_native._profile import ( # pyright: ignore[reportMissingImports] + Choice, + ) + + from solprop.calibration.controller import CalibrationReport + +__all__ = [ + "MachineProfile", + "calibrate", + "clear", + "export_profile", + "import_profile", + "profile", + "store_directory", +] + + +@dataclass(frozen=True) +class MachineProfile: + """The calibration this process would apply, and its evidence. + + Attributes + ---------- + fingerprint : str + The machine and build's store key. + generation : str + The published generation's name. + created : str + When it was published, ISO 8601 in UTC. + level : str + The level that measured the row: ``"quick"``, ``"standard"`` or + ``"thorough"``. + allocation : Mapping[str, object] + The allocation class the row was measured in. + exact : bool + Whether that is this process's own class. A row of the nearest smaller class + supplies workspace settings only. + process : Mapping[str, object] + The row's process settings (pool width, waiting policy). + workspace : Mapping[str, object] + The row's workspace settings, each a field of the native execution record. + fitted_under : Mapping[str, object] + The process settings the workspace settings were validated under. + evidence : Mapping[str, object] + What was measured, and why each setting was kept. + source : pathlib.Path or None + The generation file read. + """ + + fingerprint: str + generation: str + created: str + level: str + allocation: Mapping[str, object] + exact: bool + process: Mapping[str, object] + workspace: Mapping[str, object] + fitted_under: Mapping[str, object] + evidence: Mapping[str, object] + source: Path | None + + +def calibrate( + level: str = "standard", budget: float | None = None +) -> CalibrationReport: + """Calibrate this machine's native backend for the process's allocation. + + Child processes measure the candidate settings under one deadline; the result is + published in the user's store only if it was validated (SEP 46 section 6.5), and + later runs apply it. ``solprop calibrate`` is the same from the command line. + + Parameters + ---------- + level : str, optional + ``"quick"`` (about 20 s), ``"standard"`` (about 90 s) or ``"thorough"`` + (about five minutes). + budget : float or None, optional + The deadline in seconds, the level's when ``None``. + + Returns + ------- + solprop.calibration.controller.CalibrationReport + Each setting chosen beside its built-in value, the evidence, and whether it + was published. + """ + from solprop.calibration.controller import calibrate as run # noqa: PLC0415 + + return run(level, budget) + + +def store_directory(environ: Mapping[str, str] | None = None) -> Path: + r"""Return the user's calibration store: the platform's user cache directory. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + pathlib.Path + ``SOLPROP_CACHE_DIR`` when set; otherwise ``$XDG_CACHE_HOME/solprop`` or + ``~/.cache/solprop`` on Linux, ``~/Library/Caches/solprop`` on macOS and + ``%LOCALAPPDATA%\solprop\Cache`` on Windows. + + Examples + -------- + >>> store_directory({"SOLPROP_CACHE_DIR": "/tmp/solprop"}).as_posix() + '/tmp/solprop' + """ + return _native.store.cache_directory(os.environ if environ is None else environ) + + +def choice(environ: Mapping[str, str] | None = None) -> Choice: + """Return what this process's native import would find in the stores. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + solprop_native._profile.Choice + The fingerprint, the allocation class, the generation and its row. + """ + return _native.profile.choose( + os.environ if environ is None else environ, + packages=_native.packages(), + stem=_native.variant(), + cpus=_process.allowed_cpus(), + quota=_process.cgroup_cpus(), + ) + + +def profile(environ: Mapping[str, str] | None = None) -> MachineProfile | None: + """Return the calibration this process would apply, or ``None``. + + ``None`` means the native backend runs its built-in table: no profile was + published for this machine and build, none has a row for this allocation, or + ``SOLPROP_CALIBRATION=off``. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + MachineProfile or None + The profile, its row and evidence. + """ + found = choice(environ) + if found.generation is None or found.found is None: + return None + row = found.found.row + return MachineProfile( + fingerprint=found.generation.fingerprint, + generation=found.generation.name, + created=found.generation.created, + level=row.level, + allocation=row.allocation, + exact=found.found.exact, + process=row.process, + workspace=row.workspace, + fitted_under=row.fitted_under, + evidence=row.evidence, + source=found.generation.source, + ) + + +def export_profile(path: Path, environ: Mapping[str, str] | None = None) -> Path: + """Write this machine and build's published generation to `path`. + + The file can be imported elsewhere (:func:`import_profile`) or placed in a + directory listed in ``SOLPROP_CALIBRATION_PATH``, for example a cluster's + profiles for each node type. + + Parameters + ---------- + path : pathlib.Path + The JSON file to write. + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + pathlib.Path + The generation file that was exported. + + Raises + ------ + CalibrationError + When no generation was published for this machine and build. + """ + found = choice(environ) + if found.generation is None or found.generation.source is None: + raise CalibrationError( + "no calibration profile for this machine and build to export; run " + "`solprop calibrate` first" + ) + path.write_text( + json.dumps(found.generation.record(), indent=1) + "\n", encoding="utf-8" + ) + return found.generation.source + + +def import_profile(path: Path, environ: Mapping[str, str] | None = None) -> bool: + """Publish an exported generation in the user's store. + + Parameters + ---------- + path : pathlib.Path + A file written by :func:`export_profile`. + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + bool + Whether the generation is for this machine and build, so that it applies + here; another fingerprint's is kept for the store's other machines. + + Raises + ------ + CalibrationError + When the file is not a generation of this format. + """ + store = _native.store + try: + generation = store.Generation.read(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError) as error: + raise CalibrationError(f"cannot import {path}: {error}") from error + store.Store(store_directory(environ)).publish(generation) + found = choice(environ) + return found.fingerprint is not None and ( + found.fingerprint.digest == generation.fingerprint + ) + + +def clear(environ: Mapping[str, str] | None = None) -> int: + """Forget this machine and build's profiles in the user's store. + + A calibration in progress keeps its claim. Read-only directories are not touched. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + int + The files removed. + """ + found = choice(environ) + if found.fingerprint is None: + return 0 + return _native.store.Store(store_directory(environ)).clear(found.fingerprint.digest) diff --git a/src/solprop/calibration/_native.py b/src/solprop/calibration/_native.py new file mode 100644 index 000000000..d7b99f5d6 --- /dev/null +++ b/src/solprop/calibration/_native.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The standard-library modules of solprop-native, loaded without its runtime. + +A calibration's controller must not load the native extension or a device runtime +(solprop SEP 46 section 6.2), and importing ``solprop_native`` starts its OpenMP +runtime. Its store, topology, fingerprint, allocation, table and profile modules +import nothing but the standard library and each other, so they are loaded here by +path into a private package of their own, as the backend registry loads its processor +check (``backends/registry.py``). Nothing here runs ``solprop_native/__init__``. + +Use them as ``from solprop.calibration import _native`` and then ``_native.store``, +``_native.profile`` and so on; each loads on first use. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING + +from solprop.exceptions import BackendError + +if TYPE_CHECKING: + from solprop_native import ( # pyright: ignore[reportMissingImports] + _allocation as allocation, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _cpu as cpu, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _execution as execution, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _fingerprint as fingerprint, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _profile as profile, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _store as store, + ) + from solprop_native import ( # pyright: ignore[reportMissingImports] + _topology as topology, + ) + +__all__ = [ + "allocation", + "cpu", + "execution", + "fingerprint", + "packages", + "profile", + "store", + "topology", + "variant", +] + +_PACKAGE = "_solprop_calibration_native" +_MODULES = ( + "allocation", + "cpu", + "execution", + "fingerprint", + "profile", + "store", + "topology", +) + + +def packages() -> tuple[Path, ...]: + """Return the installed ``solprop_native`` package's directories. + + An editable install keeps its sources and its built files (the extension, + ``_build.json``) in separate directories; a wheel has one. + + Returns + ------- + tuple of pathlib.Path + The package's directories, in import order. + + Raises + ------ + BackendError + When solprop-native is not installed. + """ + spec = importlib.util.find_spec("solprop_native") + if spec is None or not spec.submodule_search_locations: + raise BackendError( + "a machine calibration needs the native backend: install solprop-native" + ) + return tuple(Path(entry) for entry in spec.submodule_search_locations) + + +def variant() -> str: + """Return the extension the native import chooses (``_cpu.select_variant``). + + Asked of the directory that holds the package's sources, as its ``__init__`` + asks it of its own. + + Returns + ------- + str + The extension's stem, ``"_core"`` for a build with one. + """ + for directory in packages(): + if (directory / "_cpu.py").is_file(): + return __getattr__("cpu").select_variant(directory) + return "_core" + + +def __getattr__(name: str) -> ModuleType: + """Load one of the package's pure modules under the private package. + + Parameters + ---------- + name : str + One of the pure modules, without its underscore. + + Returns + ------- + types.ModuleType + The module. + + Raises + ------ + AttributeError + For any other name. + """ + if name not in _MODULES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + if _PACKAGE not in sys.modules: + parent = ModuleType(_PACKAGE) + parent.__path__ = [str(entry) for entry in packages()] + sys.modules[_PACKAGE] = parent + return importlib.import_module(f"{_PACKAGE}._{name}") diff --git a/src/solprop/calibration/child.py b/src/solprop/calibration/child.py new file mode 100644 index 000000000..b0a124aca --- /dev/null +++ b/src/solprop/calibration/child.py @@ -0,0 +1,470 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A calibration child: one candidate's process settings, timed on request (SEP 46). + +The controller starts one child per candidate process setting (SEP 46 section 6.2), +with the setting in its environment, and drives it over JSON lines on its standard +input and output. The child pins itself to the allocation being calibrated before the +native runtime starts, builds the calibration problems, and then times their windows +as the controller asks, under the workspace record it is given or its process's own. +It never calibrates and never reads a profile (``SOLPROP_CALIBRATION=off``). + +Run as ``python -m solprop.calibration.child CPUS``, ``CPUS`` a comma-separated list +of logical CPUs (empty for no pinning). Requests:: + + {"op": "prepare", "problems": {"gnlse": 1.0, ...}} + {"op": "time", "problem": "gnlse", "repeats": 40, "record": {...} | null, + "planning": {...} | null} + {"op": "sizing", "record": {...} | null} + {"op": "capture", "effort": "measure", "limit": 2.0, "budget": 30.0, + "record": {...} | null} + {"op": "regions", "elements": 1000, "repeats": 200} + {"op": "exit"} + +Each request has one reply line, ``{"ok": ...}`` or ``{"error": "..."}``. +""" + +from __future__ import annotations + +import io +import json +import os +import statistics +import sys +import time +import warnings +from collections.abc import Callable, Iterable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from solprop_native._execution import ( # pyright: ignore[reportMissingImports] + ExecutionRecord, + ) + from solprop_native.backend import ( # pyright: ignore[reportMissingImports] + NativeBackend, + ) + +__all__ = ["main", "pin", "serve"] + + +def pin(cpus: Sequence[int]) -> None: + """Pin this process to `cpus` before the native runtime starts. + + Linux sets its affinity mask; Windows its process affinity mask (CPUs of the first + processor group); macOS has no affinity, and is left alone. + + Parameters + ---------- + cpus : sequence of int + Logical CPUs; empty leaves the process as it is. + """ + if not cpus: + return + if hasattr(os, "sched_setaffinity"): + os.sched_setaffinity(0, cpus) + elif sys.platform == "win32": # pragma: no cover - Windows + import ctypes # noqa: PLC0415 - Windows only + + mask = sum(1 << cpu for cpu in cpus if cpu < 64) + # A private handle on the DLL, so that declaring the types touches no one + # else's; a HANDLE and a DWORD_PTR are pointer wide. + kernel = ctypes.WinDLL("kernel32") + kernel.GetCurrentProcess.restype = ctypes.c_void_p + kernel.SetProcessAffinityMask.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + kernel.SetProcessAffinityMask(kernel.GetCurrentProcess(), mask) + + +_PLANS_PER_PROBLEM = 4 +"""About how many transforms a calibration problem plans, for sharing a budget.""" + +_SHORTEST_LIMIT = 0.05 +"""The shortest per-plan time limit worth a problem's capture, in seconds.""" + + +class _Session: + """The child's problems, and the backends built for each record.""" + + def __init__(self) -> None: + self.problems: dict[str, tuple[Any, Any]] = {} + self.backends: dict[ + tuple[str, str], tuple[NativeBackend, Callable[[], object]] + ] = {} + + def backend( + self, + name: str, + record: Mapping[str, object] | None, + planning: Mapping[str, object] | None = None, + ) -> Callable[[], object]: + """Return the trial step of problem `name` under `record`, built once. + + Parameters + ---------- + name : str + A prepared problem. + record : Mapping[str, object] or None + A native execution record, or ``None`` for the process's own. + planning : Mapping[str, object] or None, optional + An FFT planning (``fft_planning``), or ``None`` for the plan policy. + + Returns + ------- + collections.abc.Callable + One trial step from the problem's initial state. + """ + key = self._key(name, record, planning) + if key not in self.backends: + from solprop_native._execution import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + ExecutionRecord, + ) + from solprop_native.backend import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + NativeBackend, + ) + + simulation, problem = self.problems[name] + execution: ExecutionRecord | None = ( + None if record is None else ExecutionRecord.from_native(record) + ) + backend = NativeBackend( + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + cpu_execution=execution, + fft_planning=planning, + ) + backend.set_initial(problem.initial.field0, None, z=problem.initial.z) + step = backend.timed_step() + step() + self.backends[key] = (backend, step) + return self.backends[key][1] + + @staticmethod + def _key( + name: str, + record: Mapping[str, object] | None, + planning: Mapping[str, object] | None = None, + ) -> tuple[str, str]: + """Return the cache key of problem `name`'s backend under a setup. + + Parameters + ---------- + name : str + A prepared problem. + record, planning : Mapping[str, object] or None + The setup. + + Returns + ------- + tuple of str + The problem and its setup as JSON. + """ + return name, json.dumps([record, planning], sort_keys=True) + + def prepare(self, problems: Mapping[str, float]) -> dict[str, object]: + """Build each problem at its scale and time one step of each. + + Parameters + ---------- + problems : Mapping[str, float] + Each calibration problem's scale. + + Returns + ------- + dict + Each problem's fastest step and shape, the process settings in force, its + CPUs, and its execution and built-in records. + """ + import solprop_native # noqa: PLC0415 # pyright: ignore[reportMissingImports] + + from solprop.calibration.problems import build # noqa: PLC0415 + + report: dict[str, object] = {} + for name, scale in problems.items(): + simulation = build(name, float(scale)) + problem = simulation.translate() + self.problems[name] = (simulation, problem) + step = self.backend(name, None) + # The fastest of a few steps: the first ones still fault pages in. + seconds = [] + for _ in range(3): + begun = time.perf_counter() + step() + seconds.append(time.perf_counter() - begun) + report[name] = {"step": min(seconds), "shape": list(problem.shape)} + return { + "problems": report, + "process": solprop_native.PROCESS.record(), + "cpus": sorted(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else [], + "execution": solprop_native.EXECUTION.native(), + "builtin": solprop_native.BUILTIN_EXECUTION.native(), + } + + def time( + self, + name: str, + repeats: int, + record: Mapping[str, object] | None, + planning: Mapping[str, object] | None = None, + ) -> float: + """Return the seconds a trial step of `name` takes under `record`. + + Two untimed steps first bring the problem's arrays back into the caches, + which another child's window has just used. + + Parameters + ---------- + name : str + A prepared problem. + repeats : int + The trial steps timed. + record : Mapping[str, object] or None + A native execution record, or ``None`` for the process's own. + planning : Mapping[str, object] or None, optional + An FFT planning, or ``None`` for the plan policy. + + Returns + ------- + float + Seconds per step. + """ + step = self.backend(name, record, planning) + for _ in range(2): + step() + begun = time.perf_counter() + for _ in range(repeats): + step() + return (time.perf_counter() - begun) / repeats + + def capture( + self, + effort: str, + limit: float, + record: Mapping[str, object] | None, + budget: float | None = None, + ) -> dict[str, object]: + """Plan every problem's transforms alone at `effort`; return their wisdom. + + Each transform is seeded from no wisdom, within `limit` seconds, and its + wisdom kept by its plan key (SEP 46 section 6.7). The workspaces built are + closed: only the wisdom is wanted. Within a `budget`, each problem's plans + share what is left of it, at most `limit` each, and the problems it leaves no + time for are skipped and listed. + + Parameters + ---------- + effort : str + ``"measure"`` or ``"patient"``. + limit : float + FFTW's planning time limit per transform, in seconds. + record : Mapping[str, object] or None + The execution record the problems run under, whose widths the plans' + threads follow. + budget : float or None, optional + The seconds the whole capture may take; none bounds it but `limit`. + + Returns + ------- + dict + ``wisdom`` by plan key, each key's ``groups`` and the problems + containing it (``problems``), the problems skipped for want of time + (``skipped``), and the seconds spent (``seconds``). + """ + from solprop_native._execution import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + ExecutionRecord, + ) + from solprop_native.backend import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + NativeBackend, + ) + + wisdom: dict[str, str] = {} + groups: dict[str, str] = {} + contains: dict[str, list[str]] = {} + skipped: list[str] = [] + begun = time.perf_counter() + names = list(self.problems) + for index, name in enumerate(names): + simulation, problem = self.problems[name] + each = limit + if budget is not None: + left = budget - (time.perf_counter() - begun) + # A problem plans a few transforms: share what is left among them. + each = min(limit, left / (_PLANS_PER_PROBLEM * (len(names) - index))) + if each < _SHORTEST_LIMIT: + skipped.extend(names[index:]) + break + backend = NativeBackend( + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + cpu_execution=None + if record is None + else ExecutionRecord.from_native(record), + fft_planning={ + "mode": "capture", + "capture_effort": effort, + "time_limit": each, + }, + ) + try: + found = backend._native_state.fft_planning # pyright: ignore[reportPrivateUsage] + finally: + backend.close() + wisdom |= cast(dict[str, str], found["captured"]) + for key, group in cast(dict[str, str], found["groups"]).items(): + groups[key] = group + contains.setdefault(name, []).append(key) + return { + "wisdom": wisdom, + "groups": groups, + "problems": contains, + "skipped": skipped, + "seconds": time.perf_counter() - begun, + } + + def sizing(self, record: Mapping[str, object] | None) -> dict[str, object]: + """Return each problem's operations as its workspace sizes them. + + Parameters + ---------- + record : Mapping[str, object] or None + A native execution record, or ``None`` for the process's own. + + Returns + ------- + dict + Each problem's ``execution_sizing``: work and width by operation. + """ + found: dict[str, object] = {} + for name in self.problems: + self.backend(name, record) + backend = self.backends[self._key(name, record)][0] + found[name] = backend.metadata()["execution_sizing"] + return found + + def close(self) -> None: + """Close every backend.""" + for backend, _ in self.backends.values(): + backend.close() + self.backends.clear() + + +def _regions(elements: int, repeats: int) -> dict[str, float]: + """Return the cost of a parallel region at the process's width, in seconds. + + The median of `repeats` timed scaled copies of `elements` complex values on the + kernels' execution space (``reference_stream_copies``), and of single-element + copies, whose cost is the region's own. + + Parameters + ---------- + elements : int + The complex values of each copy. + repeats : int + The timed copies of each size. + + Returns + ------- + dict of str to float + ``copy`` and ``empty``: the median seconds of each. + """ + from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + _core, + ) + + copies = _core.reference_stream_copies(elements, 20, repeats) + empty = _core.reference_stream_copies(1, 20, repeats) + return {"copy": statistics.median(copies), "empty": statistics.median(empty)} + + +def serve(requests: Iterable[str], replies: io.TextIOBase) -> None: + """Answer requests until ``exit`` or the end of input. + + Parameters + ---------- + requests : Iterable[str] + JSON lines, one request each. + replies : io.TextIOBase + One JSON line written per request. + """ + session = _Session() + try: + for line in requests: + if not line.strip(): + continue + request = json.loads(line) + op = request.get("op") + if op == "exit": + replies.write(json.dumps({"ok": True}) + "\n") + replies.flush() + return + try: + if op == "prepare": + answer: object = session.prepare(request["problems"]) + elif op == "time": + answer = session.time( + request["problem"], + int(request["repeats"]), + request.get("record"), + request.get("planning"), + ) + elif op == "capture": + answer = session.capture( + request["effort"], + float(request["limit"]), + request.get("record"), + None + if request.get("budget") is None + else float(request["budget"]), + ) + elif op == "sizing": + answer = session.sizing(request.get("record")) + elif op == "regions": + answer = _regions(int(request["elements"]), int(request["repeats"])) + else: + raise ValueError(f"unknown request {op!r}") + reply: dict[str, object] = {"ok": answer} + except Exception as error: + reply = {"error": f"{type(error).__name__}: {error}"} + replies.write(json.dumps(reply) + "\n") + replies.flush() + finally: + session.close() + + +def main(argv: Sequence[str] | None = None) -> int: + """Pin, then serve the controller on the process's standard streams. + + The protocol keeps a private copy of standard output; anything else the process + or the native runtime prints goes to standard error. + + Parameters + ---------- + argv : sequence of str or None, optional + ``[CPUS]``; the process's own arguments when ``None``. + + Returns + ------- + int + Zero. + """ + arguments = list(sys.argv[1:] if argv is None else argv) + cpus = [int(cpu) for cpu in arguments[0].split(",") if cpu] if arguments else [] + pin(cpus) + # The synthetic problems' own warnings (an absorber on a small ladder size, say) + # concern nothing the user built. + warnings.simplefilter("ignore") + protocol = os.fdopen(os.dup(1), "w", encoding="utf-8") + os.dup2(2, 1) + sys.stdout = sys.stderr + serve(sys.stdin, protocol) + return 0 + + +if __name__ == "__main__": # pragma: no cover - run as a module by the controller + raise SystemExit(main()) diff --git a/src/solprop/calibration/command.py b/src/solprop/calibration/command.py new file mode 100644 index 000000000..0aa013790 --- /dev/null +++ b/src/solprop/calibration/command.py @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""``solprop calibrate``: measure, show, export, import or forget a profile (SEP 46). + +The command line's parser (``solprop.cli``) hands this module its arguments. Nothing +here loads the native extension. +""" + +from __future__ import annotations + +import argparse +import os +import platform +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import cast + +from solprop import calibration +from solprop.calibration import _native + +__all__ = ["add_arguments", "describe", "run"] + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """Add ``solprop calibrate``'s options to `parser`. + + Parameters + ---------- + parser : argparse.ArgumentParser + The subcommand's parser. + """ + action = parser.add_mutually_exclusive_group() + action.add_argument( + "--quick", + dest="level", + action="store_const", + const="quick", + help="about 20 s: the waiting policy and the team at reduced depth", + ) + action.add_argument( + "--thorough", + dest="level", + action="store_const", + const="thorough", + help="about five minutes: more rounds and longer windows", + ) + action.add_argument( + "--recipe", + metavar="FILE", + type=Path, + help="refine the settings for one simulation, a recipe (stage 2)", + ) + action.add_argument( + "--show", + action="store_true", + help="show the profile this allocation would use, and its evidence", + ) + action.add_argument( + "--export", + metavar="FILE", + type=Path, + help="write this machine and build's profile to FILE", + ) + action.add_argument( + "--import", + dest="import_", + metavar="FILE", + type=Path, + help="publish an exported profile in your store", + ) + action.add_argument( + "--clear", + action="store_true", + help="forget this machine and build's profiles", + ) + parser.add_argument( + "--budget", + type=float, + default=None, + metavar="SECONDS", + help="the calibration's deadline, the level's when omitted", + ) + parser.set_defaults(level="standard") + + +def _value(value: object) -> str: + """Format one setting for the description. + + Parameters + ---------- + value : object + A setting's value. + + Returns + ------- + str + ``yes`` or ``no`` for a flag, else the value as text. + """ + if isinstance(value, bool): + return "yes" if value else "no" + return str(value) + + +def _summary(record: Mapping[str, object]) -> str: + """Describe an allocation class in a line. + + Parameters + ---------- + record : Mapping[str, object] + An allocation class's record. + + Returns + ------- + str + Its cores, kinds, spans, SMT and quota. + """ + cores = record.get("cores") + kinds = record.get("kinds") + parts = [f"{cores} cores"] + if isinstance(kinds, list) and len(kinds) > 1: # pyright: ignore[reportUnknownArgumentType] + parts.append("of kinds " + " + ".join(str(kind) for kind in kinds)) # pyright: ignore[reportUnknownVariableType] + for name, label in ( + ("sockets", "sockets"), + ("numa_nodes", "NUMA nodes"), + ("l3_groups", "L3 groups"), + ): + if record.get(name, 1) != 1: + parts.append(f"{record.get(name)} {label}") + if record.get("smt"): + parts.append("SMT") + if record.get("quota") is not None: + parts.append(f"quota {record.get('quota')}") + return ", ".join(parts) + + +def _evidence(evidence: Mapping[str, object]) -> list[str]: + """Summarise a row's evidence: why, the noise, each verdict, the validation. + + Parameters + ---------- + evidence : Mapping[str, object] + A row's evidence. + + Returns + ------- + list of str + One line each. + """ + lines: list[str] = [] + if "reason" in evidence: + lines.append(f"decided: {evidence['reason']}") + if "noise" in evidence: + lines.append(f"noise: {_value(evidence['noise'])} (A/A)") + verdicts = evidence.get("verdicts") + if isinstance(verdicts, dict): + for name, verdict in cast(dict[str, dict[str, object]], verdicts).items(): + score = verdict.get("score") + shown = f"{score:.3f}" if isinstance(score, float) else "-" + lines.append(f"candidate: {name} {shown} ({verdict.get('reason')})") + validation = evidence.get("validation") + if isinstance(validation, dict): + lines.append( + "validation: " + + str(cast(dict[str, object], validation).get("reason", "")) + ) + return lines + + +def describe(environ: Mapping[str, str] | None = None) -> str: + """Return ``solprop calibrate --show``'s text. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + str + The store, the machine, the build, the allocation, and the profile's + settings beside the built-in table's, or why none applies. + """ + found = calibration.choice(environ) + lines = [f"store: {calibration.store_directory(environ)}"] + for root in _native.store.search_path(os.environ if environ is None else environ): + lines.append(f"read-only: {root}") + lines.append(f"mode: SOLPROP_CALIBRATION={found.mode}") + if found.fingerprint is None or found.allocation is None: + lines.append("profile: none (the built-in table applies)") + return "\n".join(lines) + "\n" + machine = found.fingerprint.machine + build = found.fingerprint.build + record = build.get("record") + details = ( + [ + str(record.get(key)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + for key in ("compiler_id", "compiler_version", "openmp_libraries") + ] + if isinstance(record, dict) + else ["no build record"] + ) + lines += [ + f"machine: {machine.get('model')} ({machine.get('system')} " + f"{machine.get('architecture')})", + f"build: solprop-native {build.get('version')}, {' '.join(details)}", + f"fingerprint: {found.fingerprint.digest}", + f"allocation: {_summary(found.allocation.record())}", + ] + if found.generation is None or found.found is None: + lines.append(f"profile: none: {found.note} (the built-in table applies)") + return "\n".join(lines) + "\n" + row = found.found.row + scope = ( + "this allocation" + if found.found.exact + else f"nearest smaller allocation ({_summary(row.allocation)}): workspace " + "settings only" + ) + lines += [ + f"profile: {row.level}, generation {found.generation.name}, " + f"{found.generation.created}", + f" measured on {scope}", + f"file: {found.generation.source}", + ] + if row.process: + lines.append( + "process: " + + ", ".join( + f"{name} {_value(value)}" for name, value in row.process.items() + ) + ) + table = _native.execution.builtin(platform.system()).native() + for name, value in row.workspace.items(): + lines.append( + f"workspace: {name} {_value(value)} (built-in {_value(table.get(name))})" + ) + if row.fitted_under: + fitted = ", ".join(f"{k} {_value(v)}" for k, v in row.fitted_under.items()) + lines.append(f"fitted under: {fitted}") + lines += _evidence(row.evidence) + return "\n".join(lines) + "\n" + + +def run(arguments: argparse.Namespace) -> int: + """Carry out ``solprop calibrate``. + + Parameters + ---------- + arguments : argparse.Namespace + The parsed options of :func:`add_arguments`. + + Returns + ------- + int + The exit status: zero on success. + + Raises + ------ + CalibrationError + When there is nothing to export, a file cannot be imported, or no action + was asked for. + """ + if arguments.show: + sys.stdout.write(describe()) + return 0 + if arguments.recipe is not None: + from solprop.calibration.refine import refine # noqa: PLC0415 - heavy + from solprop.recipe import Recipe # noqa: PLC0415 - imports the physics + + refined = refine( + Recipe.load(arguments.recipe).build(), + arguments.budget or 60.0, + progress=lambda line: sys.stderr.write(f"solprop calibrate: {line}\n"), + ) + sys.stdout.write(refined.describe()) + return 0 if refined.status != "inconclusive" else 1 + if arguments.export is not None: + source = calibration.export_profile(arguments.export) + sys.stdout.write(f"exported {source} to {arguments.export}\n") + return 0 + if arguments.import_ is not None: + applies = calibration.import_profile(arguments.import_) + note = ( + "it applies to this machine and build" + if applies + else "it is for another machine or build, and does not apply here" + ) + sys.stdout.write(f"imported {arguments.import_}: {note}\n") + return 0 + if arguments.clear: + removed = calibration.clear() + sys.stdout.write( + f"removed {removed} file(s) from {calibration.store_directory()}\n" + ) + return 0 + from solprop.calibration.controller import calibrate # noqa: PLC0415 - heavy + + report = calibrate( + arguments.level, + arguments.budget, + progress=lambda line: sys.stderr.write(f"solprop calibrate: {line}\n"), + ) + sys.stdout.write(report.describe()) + return 0 if report.status == "published" else 1 diff --git a/src/solprop/calibration/controller.py b/src/solprop/calibration/controller.py new file mode 100644 index 000000000..65c9d0385 --- /dev/null +++ b/src/solprop/calibration/controller.py @@ -0,0 +1,1909 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The calibration controller: children, rounds, a deadline and publication (SEP 46). + +The controller never loads the native extension (section 6.2). It starts one child +per candidate process setting (:mod:`solprop.calibration.child`), each pinned to the +allocation being calibrated, plus one child on the built-in rules, and times the +calibration problems' windows in interleaved rounds: in each round every child once, +in a fresh order, and the built-in child twice for the A/A noise. The selection rule +(:mod:`solprop.calibration.selection`) admits a candidate only if it wins every round +by more than the noise; the winner is then validated against the built-in child on +fresh rounds, window by window. Everything runs under one monotonic deadline per +level (section 6.9): a child that overruns is terminated, a stage that cannot finish +its rounds is skipped and reported, and nothing partial is published. Publication +writes one complete generation, keeping the other allocation classes' rows, and +swaps ``current`` (section 6.8). +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import json +import math +import os +import queue +import random +import subprocess +import sys +import threading +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +from solprop import _process +from solprop.calibration import _native, grain, selection +from solprop.exceptions import CalibrationError + +if TYPE_CHECKING: + from solprop_native._allocation import ( # pyright: ignore[reportMissingImports] + AllocationClass, + ) + from solprop_native._store import ( # pyright: ignore[reportMissingImports] + Generation, + Row, + Store, + ) + from solprop_native._topology import ( # pyright: ignore[reportMissingImports] + Topology, + ) + +__all__ = [ + "LEVELS", + "ORDER", + "POLICY", + "SINGLE_CPU", + "CalibrationReport", + "Candidate", + "Level", + "LevelName", + "builtin_wait", + "calibrate", + "candidates", + "nested", +] + +type LevelName = Literal["quick", "standard", "thorough"] +"""A measuring level's name.""" +type Scores = dict[str, list[float | None]] + + +@dataclass(frozen=True) +class Level: + """One calibration level's budget and depth. + + Attributes + ---------- + name : str + ``"quick"``, ``"standard"`` or ``"thorough"``. + budget : float + Its deadline, in seconds, for all of its work. + rounds : int + Interleaved rounds per comparison, and the fewest that decide. + window : float + The seconds each timed window aims at. + problems : tuple of str + The calibration problems timed. + widths : int + The team widths tried besides the built-in rule's. + grain : int + The most grain candidates timed. + fulls : bool + Whether the grain moves the classes' full sizes as well as their thresholds. + reserve : float + The share of the budget kept for validation and publication. + planning : float + The share of the time left after the waiting and team that the grain stage + leaves to FFT planning, which follows it; none at the quick level, which keeps + the plan policy. + """ + + name: LevelName + budget: float + rounds: int + window: float + problems: tuple[str, ...] + widths: int + grain: int + fulls: bool = False + reserve: float = 0.2 + planning: float = 0.5 + + +_ALL = ("gnlse", "capillary", "averaged", "radial", "line") +_LARGE = ("gnlse_large", "capillary_large", "radial_large", "line_large") +"""Production-sized problems, which no narrower team or grain move may slow down: a +setting that wins only on small problems loses on large ones (SEP 46 Part II).""" + +LEVELS: dict[str, Level] = { + # Quick: the wait, the team and the coarse grain (medium width, compact team). + # Quick has no production-sized problem, so it tries no narrower team. + "quick": Level( + "quick", 20.0, 3, 0.08, ("gnlse", "averaged", "line"), 0, 4, planning=0.0 + ), + "standard": Level("standard", 90.0, 5, 0.08, _ALL + _LARGE, 2, 10, reserve=0.15), + "thorough": Level("thorough", 300.0, 7, 0.3, _ALL + _LARGE, 2, 40, fulls=True), +} +"""The levels: about 20 s, a minute and a half and five minutes (SEP 46 section 6.9).""" + +ORDER: dict[str, int] = {"quick": 0, "standard": 1, "thorough": 2} +"""Levels by depth: a higher level replaces a lower one for the same allocation.""" + +SINGLE_CPU = "one CPU: no team, waiting or grain to choose (the built-in table applies)" +"""Why an allocation of one CPU is never measured. + +A standard calibration of one core of an AMD EPYC 7763 ran out of its 90 s before three +rounds of its problems in series, and could not have chosen anything (SEP 46 Part II). +""" + +_CLEARED = ( + "SOLPROP_THREADS", + "SOLPROP_NATIVE_THREADS", + "SOLPROP_NATIVE_KERNEL_THREADS", + "SOLPROP_NATIVE_FFT_THREADS", + "SOLPROP_NATIVE_RAMAN_FFT_THREADS", + "SOLPROP_NATIVE_RAMAN_TAIL_FFT_THREADS", + "SOLPROP_NATIVE_THREADED_FFT_MIN_SAMPLES", + "SOLPROP_FFTW_WISDOM", + "OMP_WAIT_POLICY", + "GOMP_SPINCOUNT", + "KMP_BLOCKTIME", + "SOLPROP_NATIVE_WAIT", + "SOLPROP_NATIVE_WAIT_REQUEST", + "OMP_NUM_THREADS", +) +"""The user's thread, wait and wisdom settings, which a child never inherits.""" + + +@dataclass(frozen=True) +class Candidate: + """One candidate's process settings. + + Attributes + ---------- + threads : int or None + The pool width, or ``None`` for the built-in rule. + wait : str or None + ``"spin"`` or ``"passive"``, or ``None`` for the built-in rule. + """ + + threads: int | None = None + wait: str | None = None + + @property + def name(self) -> str: + """``"built-in"``, or the wait and width, such as ``"spin/6"``. + + Returns + ------- + str + The candidate's name in reports and scores. + """ + if self.threads is None and self.wait is None: + return "built-in" + return f"{self.wait or 'default'}/{self.threads or 'default'}" + + def environment(self, base: Mapping[str, str]) -> dict[str, str]: + """Return a child's environment, which never calibrates. + + `base` without the user's thread, wait and wisdom settings, with this + candidate's, and with FFT plans at estimate, so that no stage outside the + FFT stage spends its time planning (SEP 46 section 6.3). NumPy's and SciPy's + BLAS threads go idle at once (``solprop._process.child_environment``), + so that they do not spin on the CPUs being timed. + + Parameters + ---------- + base : Mapping[str, str] + The controller's environment. + + Returns + ------- + dict of str to str + The child's. + """ + environment = {k: v for k, v in base.items() if k not in _CLEARED} + environment |= { + "SOLPROP_CALIBRATION": "off", + "SOLPROP_FFTW_PLAN": "estimate", + "PYTHONUNBUFFERED": "1", + } + if self.threads is not None: + environment["SOLPROP_NATIVE_THREADS"] = str(self.threads) + if self.wait is not None: + environment["SOLPROP_NATIVE_WAIT_REQUEST"] = self.wait + return _process.child_environment(environment) + + +@dataclass(frozen=True) +class CalibrationReport: + """What a calibration measured, chose and published. + + Attributes + ---------- + level : str + The level run. + status : str + ``"published"``, ``"inconclusive"`` (nothing published: a busy or noisy + machine, a failed child, or too little time), or ``"held"`` (another process + is calibrating this allocation). + fingerprint : str + The machine and build's store key. + allocation : Mapping[str, object] + The allocation class calibrated. + chosen : Mapping[str, object] + Each setting chosen (the process's width and wait), the built-in values + where none replaced them. + builtin : Mapping[str, object] + The built-in rules' values. + evidence : Mapping[str, object] + The noise, every candidate's verdict, the validation and the probes. + reason : str + Why the status is what it is. + seconds : float + The wall time taken. + path : pathlib.Path or None + The published generation, if any. + """ + + level: str + status: Literal["published", "inconclusive", "held"] + fingerprint: str + allocation: Mapping[str, object] + chosen: Mapping[str, object] = field(default_factory=dict[str, object]) + builtin: Mapping[str, object] = field(default_factory=dict[str, object]) + evidence: Mapping[str, object] = field(default_factory=dict[str, object]) + reason: str = "" + seconds: float = 0.0 + path: Path | None = None + + def describe(self) -> str: + """Return the report as lines for a terminal. + + Returns + ------- + str + The status, the reason, each setting beside its built-in value, and the + profile written. + """ + lines = [ + f"calibration ({self.level}): {self.status} in {self.seconds:.1f} s", + f" {self.reason}", + ] + for name, value in self.chosen.items(): + built = self.builtin.get(name) + note = "" if value == built else f" (built-in {built})" + lines.append(f" {name}: {value}{note}") + for item in cast(list[dict[str, object]], self.evidence.get("nested", [])): + allocation = cast(dict[str, object], item["allocation"]) + lines.append( + f" nested {allocation['cores']} cores: {item['status']}, " + f"{item['reason']}" + ) + if self.path is not None: + lines.append(f" profile: {self.path}") + return "\n".join(lines) + "\n" + + +class _Deadline: + """One monotonic deadline for all of a level's work. + + Parameters + ---------- + seconds : float + The budget from now. + """ + + def __init__(self, seconds: float) -> None: + self.start = time.monotonic() + self.end = self.start + seconds + + def remaining(self) -> float: + """Return the seconds left, never negative. + + Returns + ------- + float + Seconds. + """ + return max(0.0, self.end - time.monotonic()) + + def elapsed(self) -> float: + """Return the seconds since the start. + + Returns + ------- + float + Seconds. + """ + return time.monotonic() - self.start + + def epoch(self) -> float: + """Return the deadline in seconds since the epoch, for a claim file. + + Returns + ------- + float + Seconds since the epoch. + """ + return time.time() + self.remaining() + + +class _ChildError(CalibrationError): + """A child exited, overran the deadline or reported an error.""" + + +class _Child: + """A calibration child process and its reply queue. + + Parameters + ---------- + candidate : Candidate + Its process settings. + cpus : sequence of int + The CPUs it pins itself to; empty for none. + environ : Mapping[str, str] + The controller's environment, from which the child's is made. + """ + + def __init__( + self, candidate: Candidate, cpus: Sequence[int], environ: Mapping[str, str] + ) -> None: + self.candidate = candidate + self.report: dict[str, object] = {} + self.errors: list[str] = [] + self.replies: queue.Queue[str | None] = queue.Queue() + self.process = subprocess.Popen( + [ + sys.executable, + "-m", + "solprop.calibration.child", + ",".join(str(cpu) for cpu in cpus), + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + env=candidate.environment(environ), + ) + self.readers = ( + threading.Thread(target=self._read, daemon=True), + threading.Thread(target=self._drain, daemon=True), + ) + for reader in self.readers: + reader.start() + + def _read(self) -> None: + """Queue each reply line, then ``None`` at the end of the child's output.""" + assert self.process.stdout is not None + for line in self.process.stdout: + self.replies.put(line) + self.replies.put(None) + + def _drain(self) -> None: + """Keep the child's last 20 lines of standard error, for a failure's report.""" + assert self.process.stderr is not None + for line in self.process.stderr: + self.errors.append(line.rstrip()) + del self.errors[:-20] + + def send(self, request: Mapping[str, object]) -> None: + """Write one request. + + Parameters + ---------- + request : Mapping[str, object] + The request. + + Raises + ------ + _ChildError + When the child's input is closed or broken. + """ + assert self.process.stdin is not None + try: + self.process.stdin.write(json.dumps(request) + "\n") + self.process.stdin.flush() + except (OSError, ValueError) as error: # a broken or a closed pipe + raise _ChildError(f"{self.candidate.name}: {error}") from error + + def receive(self, timeout: float) -> object: + """Return one reply's value, waiting at most `timeout` seconds. + + Parameters + ---------- + timeout : float + Seconds to wait. + + Returns + ------- + object + The reply's value. + + Raises + ------ + _ChildError + When the child overran, exited or reported an error. + """ + try: + line = self.replies.get(timeout=max(timeout, 0.0)) + except queue.Empty as error: + raise _ChildError(f"{self.candidate.name} overran the deadline") from error + if line is None: + tail = "; ".join(self.errors[-3:]) + raise _ChildError(f"{self.candidate.name} exited: {tail}") + reply = json.loads(line) + if "error" in reply: + raise _ChildError(f"{self.candidate.name}: {reply['error']}") + return reply["ok"] + + def request(self, request: Mapping[str, object], timeout: float) -> object: + """Send one request and return its reply's value. + + Parameters + ---------- + request : Mapping[str, object] + The request. + timeout : float + Seconds to wait for its reply. + + Returns + ------- + object + The reply's value. + """ + self.send(request) + return self.receive(timeout) + + def stop(self) -> None: + """Ask the child to exit, then terminate and reap it and close its pipes.""" + with contextlib.suppress(_ChildError, OSError, ValueError): + self.send({"op": "exit"}) + with contextlib.suppress(subprocess.TimeoutExpired): + self.process.wait(timeout=2.0) + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=2.0) + except subprocess.TimeoutExpired: # pragma: no cover - survived SIGTERM + self.process.kill() + self.process.wait() + # The readers end at the end of the child's output, which its exit closes. + for reader in self.readers: + reader.join(timeout=2.0) + for stream in (self.process.stdin, self.process.stdout, self.process.stderr): + with contextlib.suppress(OSError, AttributeError): # every pipe was opened + stream.close() # pyright: ignore[reportOptionalMemberAccess] + + +def builtin_wait(budget: int, top_tier: int | None) -> str: + """Return the built-in rule's wait for a pool of `budget` threads (ADR-071). + + Idle threads spin unless the team is wider than the chip's fastest kind of core. + + Parameters + ---------- + budget : int + The pool's width. + top_tier : int or None + The fastest kind's cores on a chip with several, else ``None``. + + Returns + ------- + str + ``"spin"`` or ``"passive"``. + + Examples + -------- + >>> builtin_wait(6, 6), builtin_wait(18, 6), builtin_wait(36, None) + ('spin', 'passive', 'spin') + """ + return "passive" if top_tier is not None and budget > top_tier else "spin" + + +def candidates( + level: Level, + allocation: AllocationClass, + budget: int, + top_tier: int | None = None, +) -> list[Candidate]: + """Return a level's candidate process settings for an allocation. + + The other wait at the built-in width, then narrower widths at their built-in + wait (the fastest kind's cores on a chip with several, one socket's on several + sockets, one L3 group's, half the cores), then the other wait at the first of + them; at most :data:`~solprop.calibration.selection.MAX_CANDIDATES`. + + Parameters + ---------- + level : Level + The level, whose ``widths`` bounds the narrower widths tried. + allocation : AllocationClass + The allocation being calibrated. + budget : int + The built-in rule's width for it (``thread_budget``). + top_tier : int or None, optional + The chip's fastest kind's cores, where it has several. + + Returns + ------- + list of Candidate + The candidates, none equal to the built-in rule. + """ + narrower: list[int] = [] + if len(allocation.kinds) > 1: + narrower.append(allocation.kinds[0]) + if allocation.sockets > 1: + narrower.append(allocation.cores // allocation.sockets) + if allocation.l3_groups > allocation.sockets: + narrower.append(allocation.cores // allocation.l3_groups) + narrower.append(budget // 2) + widths = sorted({width for width in narrower if 1 <= width < budget}, reverse=True) + widths = widths[: level.widths] + + def other(width: int) -> str: + """Return the wait the built-in rule does not choose at `width`. + + Parameters + ---------- + width : int + A pool's width. + + Returns + ------- + str + ``"spin"`` or ``"passive"``. + """ + return "spin" if builtin_wait(width, top_tier) == "passive" else "passive" + + found = [Candidate(threads=budget, wait=other(budget))] + found += [ + Candidate(threads=width, wait=builtin_wait(width, top_tier)) for width in widths + ] + if widths: + found.append(Candidate(threads=widths[0], wait=other(widths[0]))) + return found[: selection.MAX_CANDIDATES] + + +def nested(topology: Topology, cpus: Sequence[int]) -> list[tuple[int, ...]]: + """Return the nested allocations a calibration of `cpus` also measures. + + One socket's CPUs, one NUMA node's and one L3 group's, where `cpus` spans more + than one (SEP 46 section 6.4): one calibration of a whole node then covers most + smaller jobs on it. Equal subsets are listed once, at most two. + + Parameters + ---------- + topology : solprop_native._topology.Topology + The machine's. + cpus : sequence of int + The allocation's CPUs. + + Returns + ------- + list of tuple of int + The first group's CPUs for each kind of group spanned. + """ + found: list[tuple[int, ...]] = [] + for groups in (topology.socket, topology.numa, topology.l3): + held = [cpu for cpu in cpus if cpu in groups] + if len({groups[cpu] for cpu in held}) < 2: + continue + first = groups[held[0]] + subset = tuple(cpu for cpu in held if groups[cpu] == first) + if subset not in found: + found.append(subset) + return found[:2] + + +def _geomean(values: Sequence[float]) -> float: + """Return the geometric mean of positive `values`. + + Parameters + ---------- + values : sequence of float + Positive values. + + Returns + ------- + float + Their geometric mean. + """ + return math.exp(sum(math.log(value) for value in values) / len(values)) + + +def _telemetry(path: Path = Path("/proc/stat")) -> dict[str, float] | None: + """Return the host's running tasks and stolen time so far, or ``None``. + + Linux only (``/proc/stat``); elsewhere load is unknown and only the A/A noise + decides (SEP 46 section 6.5). + + Parameters + ---------- + path : pathlib.Path, optional + The kernel's statistics file. + + Returns + ------- + dict of str to float or None + ``total`` and ``steal`` ticks and ``running`` tasks, and each CPU's ticks + (``cpuN.total``) and busy ticks (``cpuN.busy``, neither idle nor waiting); + ``None`` where unknown. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return None + values: dict[str, float] = {} + for line in text.splitlines(): + fields = line.split() + if fields and fields[0].startswith("cpu") and len(fields) > 8: + ticks = [float(value) for value in fields[1:]] + if fields[0] == "cpu": + values["total"] = sum(ticks) + values["steal"] = ticks[7] + else: + values[f"{fields[0]}.total"] = sum(ticks) + values[f"{fields[0]}.busy"] = sum(ticks) - ticks[3] - ticks[4] + elif fields and fields[0] == "procs_running": + values["running"] = float(fields[1]) + return values + + +def _busy( + before: dict[str, float] | None, + after: dict[str, float] | None, + idle: int, + siblings: Sequence[int] = (), +) -> str: + """Return why the host was too busy to calibrate, or ``""``. + + Work not belonging to the calibration beyond the CPUs outside its allocation, an + SMT sibling of its cores more than half busy (another task sharing a core it + times), or more than a tenth of the time stolen by a hypervisor. + + Parameters + ---------- + before, after : dict or None + :func:`_telemetry` before and after the measurements. + idle : int + The CPUs outside the allocation. + siblings : sequence of int, optional + The CPUs outside the allocation that share a core with it. + + Returns + ------- + str + The reason, empty when the host was quiet enough or its load is unknown. + """ + if before is None or after is None: + return "" + # One running task is this controller, another the child being timed. + foreign = min(before.get("running", 0.0), after.get("running", 0.0)) - 2.0 + if foreign > idle: + return f"{foreign:.0f} other tasks running for {idle} idle CPUs" + for cpu in siblings: + ticks = after.get(f"cpu{cpu}.total", 0.0) - before.get(f"cpu{cpu}.total", 0.0) + busy = after.get(f"cpu{cpu}.busy", 0.0) - before.get(f"cpu{cpu}.busy", 0.0) + if ticks > 0 and busy / ticks > 0.5: + return ( + f"CPU {cpu}, an SMT sibling of the allocation's cores, was " + f"{100 * busy / ticks:.0f} % busy with other work" + ) + total = after.get("total", 0.0) - before.get("total", 0.0) + stolen = after.get("steal", 0.0) - before.get("steal", 0.0) + if total > 0 and stolen / total > 0.1: + return f"{100 * stolen / total:.0f} % of the time stolen by the hypervisor" + return "" + + +@dataclass(frozen=True) +class _Plan: + """What every allocation's session shares: level, store, deadline, progress.""" + + level: Level + environ: Mapping[str, str] + store: Store + digest: str + deadline: _Deadline + say: Callable[[str], object] + repeats: Mapping[str, int] = field(default_factory=dict[str, int]) + + +@dataclass(frozen=True) +class _Bench: + """One allocation's prepared children and the repeats of each window.""" + + builtin: _Child + rivals: tuple[_Child, ...] + repeats: Mapping[str, int] + + def time(self, plan: _Plan, child: _Child) -> list[float]: + """Return `child`'s seconds per step in each problem's window. + + Parameters + ---------- + plan : _Plan + The calibration's plan, for its problems and deadline. + child : _Child + The child to time. + + Returns + ------- + list of float + One value per problem. + """ + seconds: list[float] = [] + for problem in plan.level.problems: + reply = child.request( + {"op": "time", "problem": problem, "repeats": self.repeats[problem]}, + plan.deadline.remaining(), + ) + seconds.append(float(cast(float, reply))) + return seconds + + +def _start( + plan: _Plan, cpus: Sequence[int], allocation: AllocationClass, opened: list[_Child] +) -> _Bench: + """Start and prepare the built-in child and the candidates; size the windows. + + Every child started is appended to `opened`, so that the caller reaps it. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + cpus : sequence of int + The allocation's CPUs. + allocation : AllocationClass + Its class. + opened : list of _Child + Filled with the children started. + + Returns + ------- + _Bench + The prepared children and each window's repeats. + """ + builtin = _Child(Candidate(), cpus, plan.environ) + opened.append(builtin) + width = _native_budget(cpus) + for candidate in candidates( + plan.level, allocation, width, _process.top_tier_cores() + ): + opened.append(_Child(candidate, cpus, plan.environ)) + for child in opened: + child.send( + {"op": "prepare", "problems": dict.fromkeys(plan.level.problems, 1.0)} + ) + for child in opened: + child.report = cast(dict[str, object], child.receive(plan.deadline.remaining())) + steps = cast(dict[str, dict[str, float]], builtin.report["problems"]) + repeats = { + name: max(3, min(2000, round(plan.level.window / max(info["step"], 1e-6)))) + for name, info in steps.items() + } + return _Bench(builtin, tuple(opened[1:]), repeats) + + +def _rounds( + plan: _Plan, bench: _Bench, until: float +) -> tuple[Scores, dict[str, dict[str, list[float | None]]]]: + """Time interleaved rounds; return each slot's score and seconds in each round. + + A slot's score is the geometric mean of its seconds per step over the problems, + so that the ratio of two scores is the geometric mean of their windows' ratios. + ``"again"`` is the built-in child timed a second time in the same round. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + bench : _Bench + The prepared children. + until : float + The seconds of the deadline kept back; no round starts within them. + + Returns + ------- + tuple + Each slot's score per round, ``None`` for a failed one; and each slot's + seconds per step on each problem per round. + """ + slots = [("built-in", bench.builtin), ("again", bench.builtin)] + slots += [(child.candidate.name, child) for child in bench.rivals] + scores: Scores = {name: [] for name, _ in slots} + names = plan.level.problems + seconds = { + slot: {name: list[float | None]() for name in names} for slot, _ in slots + } + generator = random.Random(1) + for _ in range(plan.level.rounds): + if plan.deadline.remaining() <= until: + break + order = list(slots) + generator.shuffle(order) + for name, child in order: + try: + timed = list(bench.time(plan, child)) + except _ChildError: + scores[name].append(None) + for problem in names: + seconds[name][problem].append(None) + continue + scores[name].append(_geomean(timed)) + for problem, value in zip(names, timed, strict=True): + seconds[name][problem].append(value) + return scores, seconds + + +def _window_ratios( + candidate: Mapping[str, Sequence[float | None]], + base: Mapping[str, Sequence[float | None]], +) -> dict[str, list[float | None]]: + """Return each problem's per-round ratio of a candidate's seconds to a base's. + + Parameters + ---------- + candidate, base : Mapping[str, sequence of float or None] + Seconds per round by problem, ``None`` for a failed round. + + Returns + ------- + dict of str to list + The ratios, ``None`` where either failed. + """ + return { + problem: [ + None if new is None or old is None or old <= 0 else new / old + for new, old in zip(times, base[problem], strict=True) + ] + for problem, times in candidate.items() + } + + +def _validate( + plan: _Plan, bench: _Bench, winner: _Child, noise: float +) -> selection.Validation: + """Validate the winner against the built-in child on fresh rounds, by window. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + bench : _Bench + The prepared children. + winner : _Child + The admitted candidate. + noise : float + The session's A/A noise. + + Returns + ------- + solprop.calibration.selection.Validation + The verdict. + """ + names = plan.level.problems + combined: dict[str, list[float | None]] = {name: [] for name in names} + table: dict[str, list[float | None]] = {name: [] for name in names} + generator = random.Random(1000) + for _ in range(plan.level.rounds): + pair = [(combined, winner), (table, bench.builtin)] + generator.shuffle(pair) + for target, child in pair: + try: + seconds: list[float | None] = list(bench.time(plan, child)) + except _ChildError: + seconds = [None] * len(names) + for name, value in zip(names, seconds, strict=True): + target[name].append(value) + return selection.validate( + {name: (combined[name], table[name]) for name in names}, + noise, + plan.level.rounds, + ) + + +def _settings(child: _Child) -> dict[str, object]: + """Return a child's process settings as it reported them: width and wait. + + Parameters + ---------- + child : _Child + A prepared child. + + Returns + ------- + dict + ``threads`` and ``wait``. + """ + process = cast(dict[str, object], child.report["process"]) + return {"threads": process["threads"], "wait": process["wait"]} + + +def _decide( + plan: _Plan, + bench: _Bench, + scores: Scores, + noise: float, + seconds: Mapping[str, Mapping[str, Sequence[float | None]]] | None = None, +) -> tuple[_Child, dict[str, object], str]: + """Choose and validate a candidate; return its child, the evidence and why. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + bench : _Bench + The prepared children. + scores : dict + Each slot's score per round. + noise : float + The session's A/A noise. + seconds : Mapping or None, optional + Each slot's seconds per problem per round (:func:`_rounds`), so that no + problem may be slower under the winner. + + Returns + ------- + tuple + The child whose process settings were chosen (the built-in one when none + replaced them), the evidence, and the reason. + """ + windows = ( + {} + if seconds is None + else { + child.candidate.name: _window_ratios( + seconds[child.candidate.name], seconds["built-in"] + ) + for child in bench.rivals + } + ) + winner_name, verdicts = selection.choose( + {child.candidate.name: scores[child.candidate.name] for child in bench.rivals}, + scores["built-in"], + noise, + plan.level.rounds, + windows, + ) + evidence: dict[str, object] = { + "noise": noise, + "verdicts": {name: verdict.record() for name, verdict in verdicts.items()}, + } + winner = next((c for c in bench.rivals if c.candidate.name == winner_name), None) + if winner is not None: + plan.say(f"validating {winner_name}") + validation = _validate(plan, bench, winner, noise) + evidence["validation"] = validation.record() + if not validation.passed: + winner = None + regions: dict[str, object] = {} + for child in [bench.builtin] + ([winner] if winner is not None else []): + with contextlib.suppress(_ChildError): + regions[child.candidate.name] = child.request( + {"op": "regions", "elements": 1000, "repeats": 200}, + plan.deadline.remaining(), + ) + evidence["regions"] = regions + if winner is None: + return ( + bench.builtin, + evidence, + "the built-in rules kept: no candidate won every round and its validation", + ) + return winner, evidence, f"{winner_name} won every round and its validation" + + +@dataclass(frozen=True) +class _Setup: + """What one window runs under: a workspace record and an FFT planning. + + Attributes + ---------- + record : Mapping[str, int or bool] or None + A native execution record, ``None`` for the child's own. + planning : Mapping[str, object] or None + An FFT planning, ``None`` for the plan policy. + """ + + record: Mapping[str, int | bool] | None = None + planning: Mapping[str, object] | None = None + + +def _compare_round( + plan: _Plan, + child: _Child, + base: _Setup, + candidates: Sequence[tuple[str, _Setup, tuple[str, ...]]], + generator: random.Random, +) -> tuple[dict[str, float | None], dict[str, dict[str, float | None]]]: + """Time one round of candidate setups against a base setup. + + Parameters + ---------- + plan : _Plan + The calibration's plan, with each window's repeats. + child : _Child + The child whose process settings were chosen. + base : _Setup + The base setup. + candidates : sequence of tuple + Each candidate's name, setup and the problems it changes. + generator : random.Random + Shuffles the round's order. + + Returns + ------- + tuple + ``again``, the base's A/A ratio over the problems, and each candidate's ratio + to it over its problems, ``None`` for a failed window; and each candidate's + ratio on each of its problems. + """ + problems = tuple(sorted({name for _, _, names in candidates for name in names})) + slots = [("base", base, problems), ("again", base, problems), *candidates] + generator.shuffle(slots) + timed: dict[str, dict[str, float]] = {} + failed: set[str] = set() + for name, setup, names in slots: + timed[name] = {} + for problem in names: + try: + reply = child.request( + { + "op": "time", + "problem": problem, + "repeats": plan.repeats[problem], + "record": setup.record, + "planning": setup.planning, + }, + plan.deadline.remaining(), + ) + except _ChildError: + failed.add(name) + break + timed[name][problem] = float(cast(float, reply)) + ratios: dict[str, float | None] = {} + windows: dict[str, dict[str, float | None]] = {} + for name, _, names in slots: + if name == "base": + continue + if name in failed or "base" in failed: + ratios[name] = None + windows[name] = dict.fromkeys(names) + continue + each = {p: timed[name][p] / timed["base"][p] for p in names} + ratios[name] = _geomean(list(each.values())) + windows[name] = dict(each) + return ratios, windows + + +def _session_of( + plan: _Plan, + child: _Child, + base: _Setup, + candidates: Sequence[tuple[str, _Setup, tuple[str, ...]]], + until: float, + seed: int, +) -> tuple[float | None, dict[str, selection.Verdict]]: + """Time interleaved rounds of candidate setups; return the noise and verdicts. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + child : _Child + The child whose process settings were chosen. + base : _Setup + The base setup. + candidates : sequence of tuple + Each candidate's name, setup and the problems it changes. + until : float + The seconds of the deadline kept back; no round starts within them. + seed : int + Seeds the rounds' order. + + Returns + ------- + tuple + The session's A/A noise (``None`` with too few rounds), and each + candidate's verdict, empty when the noise is too high to decide. + """ + generator = random.Random(seed) + ratios: dict[str, list[float | None]] = {"again": []} + ratios |= {name: [] for name, _, _ in candidates} + windows: dict[str, dict[str, list[float | None]]] = { + name: {problem: [] for problem in names} for name, _, names in candidates + } + for _ in range(plan.level.rounds): + if plan.deadline.remaining() <= until: + break + round_ratios, round_windows = _compare_round( + plan, child, base, candidates, generator + ) + for name, value in round_ratios.items(): + ratios[name].append(value) + for name, each in round_windows.items(): + if name in windows: + for problem, value in each.items(): + windows[name][problem].append(value) + ones = [1.0] * len(ratios["again"]) + noise = selection.noise(ones, ratios["again"], plan.level.rounds) + if noise is None or noise > selection.NOISE_LIMIT: + return noise, {} + return noise, { + name: selection.admit( + ratios[name], ones, noise, plan.level.rounds, windows[name] + ) + for name, _, _ in candidates + } + + +def _grain_candidates( + plan: _Plan, child: _Child, sizing: Mapping[str, Mapping[str, object]] +) -> list[tuple[grain.Candidate, tuple[str, ...]]]: + """Return the grain candidates and the problems each changes. + + Parameters + ---------- + plan : _Plan + The calibration's plan, for its level's limits. + child : _Child + The child whose process settings were chosen. + sizing : Mapping[str, Mapping[str, object]] + Each problem's ``execution_sizing`` under the child's own record. + + Returns + ------- + list of tuple + Each candidate, with the problems whose operations (or compact team) it + changes; a candidate that changes none is left out. + """ + base = cast(dict[str, int | bool], child.report["execution"]) + capacity = int(cast(int, _settings(child)["threads"])) + shapes = cast(dict[str, dict[str, list[int]]], child.report["problems"]) + compact = [ + name for name, info in shapes.items() if math.prod(info["shape"][:-1]) == 1 + ] + found = {name: grain.operations(sizes) for name, sizes in sizing.items()} + proposed: list[tuple[grain.Candidate, tuple[str, ...]]] = [] + for candidate in grain.proposals( + base, found, capacity, compact, limit=plan.level.grain, fulls=plan.level.fulls + ): + names = tuple( + name + for name, operations in found.items() + if grain.affected(candidate.record(base), base, operations, capacity) + or (candidate.setting == "compact" and name in compact) + ) + if names: + proposed.append((candidate, names)) + return proposed + + +def _grain( + plan: _Plan, child: _Child, until: float +) -> tuple[dict[str, int | bool], dict[str, object]]: + """Fit the operation grain in the chosen process (SEP 46 phase 2). + + The child's operations, as its workspaces size them, give each operation class's + candidates (:func:`solprop.calibration.grain.proposals`); each is timed on the + problems it changes, interleaved with the child's own record, and admitted by + §6.5's rule; the best of each setting are combined and validated together on + fresh rounds of every problem. A failed validation drops the grain. + + Parameters + ---------- + plan : _Plan + The calibration's plan, with each window's repeats. + child : _Child + The child whose process settings were chosen. + until : float + The seconds of the deadline kept back for publication. + + Returns + ------- + tuple + The workspace settings admitted (empty when none), and the evidence. + """ + try: + sizing = cast( + dict[str, dict[str, object]], + child.request({"op": "sizing", "record": None}, plan.deadline.remaining()), + ) + except _ChildError as error: + return {}, {"reason": str(error)} + proposed = _grain_candidates(plan, child, sizing) + evidence: dict[str, object] = {"candidates": [c.name for c, _ in proposed]} + if not proposed: + return {}, evidence | {"reason": "no candidate changes an operation's width"} + plan.say(f"timing {len(proposed)} grain candidates") + base = cast(dict[str, int | bool], child.report["execution"]) + noise, verdicts = _session_of( + plan, + child, + _Setup(), + [(c.name, _Setup(record=c.record(base)), names) for c, names in proposed], + until, + 2000, + ) + if noise is None or not verdicts: + return {}, evidence | {"reason": "inconclusive grain noise", "noise": noise} + evidence |= { + "noise": noise, + "verdicts": {name: verdict.record() for name, verdict in verdicts.items()}, + } + best = grain.best([candidate for candidate, _ in proposed], verdicts) + if not best: + return {}, evidence | {"reason": "no grain candidate won every round"} + changes: dict[str, int | bool] = {} + for candidate in best: + changes |= candidate.changes + plan.say(f"validating {len(best)} grain settings") + validation = _validate_setups( + plan, child, _Setup(record={**base, **changes}), _Setup(), noise + ) + evidence["validation"] = validation.record() + if not validation.passed: + return {}, evidence | {"reason": "the combined grain failed its validation"} + admitted = ", ".join(candidate.name for candidate in best) + return changes, evidence | {"reason": f"admitted: {admitted}"} + + +def _validate_setups( + plan: _Plan, child: _Child, combined: _Setup, table: _Setup, noise: float +) -> selection.Validation: + """Validate a combined setup against the base on fresh rounds of every window. + + Parameters + ---------- + plan : _Plan + The calibration's plan, with each window's repeats. + child : _Child + The child. + combined : _Setup + The admitted settings together. + table : _Setup + The base they replace. + noise : float + The session's A/A noise. + + Returns + ------- + solprop.calibration.selection.Validation + The verdict. + """ + names = plan.level.problems + times: dict[str, dict[str, list[float | None]]] = { + "combined": {name: [] for name in names}, + "table": {name: [] for name in names}, + } + generator = random.Random(3000) + for _ in range(plan.level.rounds): + pair = [("combined", combined), ("table", table)] + generator.shuffle(pair) + for which, setup in pair: + for name in names: + try: + reply = child.request( + { + "op": "time", + "problem": name, + "repeats": plan.repeats[name], + "record": setup.record, + "planning": setup.planning, + }, + plan.deadline.remaining(), + ) + times[which][name].append(float(cast(float, reply))) + except _ChildError: + times[which][name].append(None) + return selection.validate( + {name: (times["combined"][name], times["table"][name]) for name in names}, + noise, + plan.level.rounds, + ) + + +POLICY: dict[str, str] = { + "rows": "measure", + "threaded_rows": "estimate", + "planes": "measure", +} +"""The plan policy's effort for each group (``fftw_seed_effort``).""" + + +def _planning_candidates( + level: Level, measured: Mapping[str, object], patient: Mapping[str, object] | None +) -> list[tuple[str, str, dict[str, object]]]: + """Return each group's candidate plannings. + + Parameters + ---------- + level : Level + The level: patient plans only at thorough, and only for serial rows. + measured, patient : Mapping[str, object] + Captures at measure and at patient effort (``patient`` ``None`` below + thorough): ``wisdom`` and ``groups`` by plan key. + + Returns + ------- + list of tuple + Each candidate's name, group and planning: the policy's efforts with one + group's changed, and the wisdom of every group it plans beyond estimate. + """ + groups = cast(dict[str, str], measured["groups"]) + wisdom = cast(dict[str, str], measured["wisdom"]) + + def planning( + efforts: Mapping[str, str], keys: Mapping[str, str] + ) -> dict[str, object]: + """Return a profile planning with `efforts` and the wisdom of `keys` they use. + + Parameters + ---------- + efforts : Mapping[str, str] + Each group's effort. + keys : Mapping[str, str] + Wisdom by plan key; that of a group planned at estimate is left out. + + Returns + ------- + dict + The planning. + """ + return { + "mode": "profile", + "efforts": dict(efforts), + "wisdom": { + key: text + for key, text in keys.items() + if efforts[groups.get(key, "rows")] != "estimate" + }, + } + + present = set(groups.values()) + found: list[tuple[str, str, dict[str, object]]] = [] + if "rows" in present: + found.append( + ("rows estimate", "rows", planning(POLICY | {"rows": "estimate"}, wisdom)) + ) + if patient is not None and level.fulls: + found.append( + ( + "rows patient", + "rows", + planning( + POLICY | {"rows": "patient"}, + wisdom | cast(dict[str, str], patient["wisdom"]), + ), + ) + ) + if "threaded_rows" in present: + found.append( + ( + "threaded_rows measure", + "threaded_rows", + planning(POLICY | {"threaded_rows": "measure"}, wisdom), + ) + ) + if "planes" in present: + found.append( + ( + "planes estimate", + "planes", + planning(POLICY | {"planes": "estimate"}, wisdom), + ) + ) + return found + + +_CAPTURE_SHARE = 0.5 +"""The share of the FFT stage's time left that each capture may take.""" + +_CAPTURE_MARGIN = 30.0 +"""Seconds past its budget a capture's child may take before it counts as failed: +FFTW's time limit is per plan, and checked between its trials.""" + + +def _planning( + plan: _Plan, child: _Child, record: Mapping[str, int | bool], until: float +) -> tuple[dict[str, object], dict[str, str], dict[str, object]]: + """Choose each FFT plan group's effort in the chosen setup (SEP 46 phase 3). + + The child plans every transform alone at measure (and at patient, thorough), + from no wisdom within the level's time limit; the policy's efforts with that + wisdom are the base, and each group's other effort a candidate, timed in the + step on the problems holding that group's transforms and admitted by §6.5's rule; + the winners are validated together. Quick leaves the plan policy. + + Parameters + ---------- + plan : _Plan + The calibration's plan, with each window's repeats. + child : _Child + The child whose process settings were chosen. + record : Mapping[str, int or bool] + The workspace record chosen, under which the transforms' threads are set. + until : float + The seconds of the deadline kept back for publication. + + Returns + ------- + tuple + The row's planning (``efforts``), the wisdom by plan key, and the evidence. + """ + if plan.level.name == "quick": + return {}, {}, {"reason": "the plan policy (quick)"} + # Most measured plans take milliseconds; the limit bounds the odd slow one. + limit = 20.0 if plan.level.fulls else 0.25 + + def capture(effort: str, share: float) -> dict[str, object]: + """Capture every problem's plans at `effort` within `share` of the time left. + + Each capture takes its share of the stage's time, so that the timing in the + step still has the rest (SEP 46 section 6.7). + + Parameters + ---------- + effort : str + ``"measure"`` or ``"patient"``. + share : float + The share of the stage's time left that the capture may take. + + Returns + ------- + dict + The child's capture (:meth:`solprop.calibration.child._Session.capture`). + """ + budget = max(0.0, plan.deadline.remaining() - until) * share + return cast( + dict[str, object], + child.request( + { + "op": "capture", + "effort": effort, + "limit": limit, + "budget": budget, + "record": dict(record), + }, + min(plan.deadline.remaining(), budget + _CAPTURE_MARGIN), + ), + ) + + try: + plan.say("planning the transforms at measure") + measured = capture("measure", _CAPTURE_SHARE) + # A capture cut short keeps what it planned: the problems it skipped are not + # timed, and plan as a run without a profile does. + if not measured["groups"]: + return {}, {}, {"reason": "no transform planned within the stage's share"} + patient = None + if plan.level.fulls: + plan.say("planning the serial transforms at patient") + patient = capture("patient", _CAPTURE_SHARE) + if patient.get("skipped"): + patient = None # incomplete: patient is not a candidate + except _ChildError as error: + return {}, {}, {"reason": str(error)} + candidates = _planning_candidates(plan.level, measured, patient) + groups = cast(dict[str, str], measured["groups"]) + contains = cast(dict[str, list[str]], measured["problems"]) + evidence: dict[str, object] = { + "plans": len(groups), + "skipped": list(cast(list[str], measured.get("skipped", []))), + "planning_seconds": measured["seconds"], + "candidates": [name for name, _, _ in candidates], + } + timed = [ + ( + name, + _Setup(record=record, planning=planning), + tuple( + p + for p, keys in contains.items() + if any(groups[k] == group for k in keys) + ), + ) + for name, group, planning in candidates + ] + timed = [item for item in timed if item[2]] + chosen = dict(POLICY) + wisdom: dict[str, str] = {} + # Every candidate, with all the wisdom it would publish, is compared with the + # built-in plan policy as a run without a profile plans: each group at the + # policy's effort, measured in place, with no stored wisdom. Stored wisdom planned + # alone can run slower in the step than plans measured in place (on 16 cores of + # an AMD EPYC 9B45 one case ran 17 % slower), so it is never assumed neutral. + # The children plan at estimate otherwise (``SOLPROP_FFTW_PLAN``), so the base + # names the policy's efforts explicitly; on that EPYC this base ran as the table. + builtin = _Setup( + record=record, + planning={"mode": "profile", "efforts": dict(POLICY), "wisdom": {}}, + ) + if timed: + plan.say(f"timing {len(timed)} FFT planning candidates") + noise, verdicts = _session_of(plan, child, builtin, timed, until, 4000) + evidence["noise"] = noise + evidence["verdicts"] = { + name: verdict.record() for name, verdict in verdicts.items() + } + admitted = { + group: (name, planning) + for name, group, planning in candidates + if name in verdicts and verdicts[name].admitted + } + if admitted and noise is not None: + for _, planning in admitted.values(): + efforts = cast(dict[str, str], planning["efforts"]) + chosen |= {g: e for g, e in efforts.items() if e != POLICY[g]} + combined = { + "mode": "profile", + "efforts": chosen, + "wisdom": { + key: text + for key, text in ( + cast(dict[str, str], measured["wisdom"]) + | (cast(dict[str, str], patient["wisdom"]) if patient else {}) + ).items() + if chosen[groups.get(key, "rows")] != "estimate" + and (chosen[groups.get(key, "rows")] != "patient" or patient) + }, + } + validation = _validate_setups( + plan, child, _Setup(record=record, planning=combined), builtin, noise + ) + evidence["validation"] = validation.record() + if validation.passed: + wisdom = cast(dict[str, str], combined["wisdom"]) + else: + chosen = dict(POLICY) + if chosen == POLICY: + evidence["reason"] = ( + "the plan policy: no effort won every round and its validation" + ) + return {}, {}, evidence + evidence["reason"] = "admitted: " + ", ".join( + f"{g} {e}" for g, e in chosen.items() if e != POLICY[g] + ) + return {"efforts": chosen}, wisdom, evidence + + +def _publish( + store: Store, + fingerprint: str, + row: Row, + allocation: AllocationClass, + wisdom: Mapping[str, str] | None = None, +) -> Path: + """Publish `row` in a new generation, keeping the other classes' rows. + + A row of the same class from a higher level is kept instead. + + Parameters + ---------- + store : solprop_native._store.Store + The user's store. + fingerprint : str + The machine and build's key. + row : solprop_native._store.Row + The new row. + allocation : AllocationClass + Its class. + wisdom : Mapping[str, str] or None, optional + Its plans' wisdom by plan key, added to the generation's. + + Returns + ------- + pathlib.Path + The generation file published. + """ + previous: Generation | None = store.current(fingerprint) + rows: list[Row] = [] + for kept in () if previous is None else previous.rows: + try: + same = ( + _native.allocation.AllocationClass.read(kept.allocation) == allocation + ) + except ValueError: + continue + if not same: + rows.append(kept) + elif ORDER.get(kept.level, -1) > ORDER[row.level]: + row = kept + rows.append(row) + return store.publish( + _native.store.Generation( + fingerprint=fingerprint, + name=_native.store.generation_name(), + created=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + rows=tuple(rows), + # The plans' wisdom accumulates: another allocation's plans keep theirs. + wisdom={**({} if previous is None else previous.wisdom), **(wisdom or {})}, + ) + ) + + +def _calibrate_allocation( + plan: _Plan, cpus: Sequence[int], allocation: AllocationClass, until: float +) -> CalibrationReport: + """Calibrate one allocation class; publish its row unless inconclusive. + + Parameters + ---------- + plan : _Plan + The calibration's plan. + cpus : sequence of int + The allocation's CPUs. + allocation : AllocationClass + Its class. + until : float + The seconds of the deadline kept back for validation and publication. + + Returns + ------- + CalibrationReport + The class's report. + """ + base = CalibrationReport( + level=plan.level.name, + status="inconclusive", + fingerprint=plan.digest, + allocation=allocation.record(), + ) + claim = plan.store.claim(plan.digest, allocation.key(), plan.deadline.epoch()) + if claim is None: + return dataclasses.replace( + base, + status="held", + reason="another process is calibrating this allocation", + seconds=plan.deadline.elapsed(), + ) + opened: list[_Child] = [] + try: + before = _telemetry() + bench = _start(plan, cpus, allocation, opened) + plan = dataclasses.replace(plan, repeats=bench.repeats) + plan.say(f"timing interleaved rounds on {allocation.cores} cores") + scores, seconds = _rounds(plan, bench, until) + noise = selection.noise(scores["built-in"], scores["again"], plan.level.rounds) + evidence: dict[str, object] = { + "rounds": plan.level.rounds, + "problems": bench.builtin.report["problems"], + "repeats": dict(bench.repeats), + "scores": scores, + } + builtin = _settings(bench.builtin) + busy = _busy( + before, + _telemetry(), + max(0, (os.cpu_count() or 1) - len(cpus)), + _siblings(cpus), + ) + if noise is None or noise > selection.NOISE_LIMIT or busy: + plan.store.record_inconclusive(plan.digest, allocation.key()) + return dataclasses.replace( + base, + reason=busy + or ( + "too few rounds within the budget" + if noise is None + else f"A/A noise {noise:.3f} above {selection.NOISE_LIMIT}" + ), + builtin=builtin, + evidence=evidence, + seconds=plan.deadline.elapsed(), + ) + final, decision, reason = _decide(plan, bench, scores, noise, seconds) + chosen = _settings(final) + # The grain stage leaves FFT planning its share of the time left, so that a + # long grain stage cannot leave the planning stage none (SEP 46 section 6.9). + left = max(0.0, plan.deadline.remaining() - until) + workspace, grain_evidence = _grain( + plan, final, until + left * plan.level.planning + ) + table = cast(dict[str, int | bool], final.report["execution"]) + planning, wisdom, planning_evidence = _planning( + plan, final, {**table, **workspace}, until + ) + reason = ( + f"{reason}; grain: {grain_evidence.get('reason')}; " + f"FFT planning: {planning_evidence.get('reason')}" + ) + evidence |= decision | { + "reason": reason, + "grain": grain_evidence, + "planning": planning_evidence, + } + row = _native.store.Row( + allocation=allocation.record(), + level=plan.level.name, + process=chosen, + workspace=workspace, + fitted_under=chosen, + evidence=evidence, + planning=planning, + ) + return CalibrationReport( + level=plan.level.name, + status="published", + fingerprint=plan.digest, + allocation=allocation.record(), + chosen=chosen | workspace, + builtin=builtin | {name: table[name] for name in workspace}, + evidence=evidence, + reason=reason, + seconds=plan.deadline.elapsed(), + path=_publish(plan.store, plan.digest, row, allocation, wisdom), + ) + except _ChildError as error: + return dataclasses.replace( + base, reason=str(error), seconds=plan.deadline.elapsed() + ) + finally: + for child in opened: + child.stop() + claim.release() + + +def calibrate( + level: str = "standard", + budget: float | None = None, + *, + environ: Mapping[str, str] | None = None, + cpus: Sequence[int] | None = None, + progress: Callable[[str], object] | None = None, +) -> CalibrationReport: + """Calibrate this machine's native backend for the process's allocation. + + At the standard and thorough levels the nested allocations it can pin children + to (one socket, one NUMA node, one L3 group) are calibrated too, while the + deadline allows; each gets its own row (SEP 46 section 6.4). + + Parameters + ---------- + level : str, optional + ``"quick"`` (about 20 s), ``"standard"`` (about 90 s) or ``"thorough"`` + (about five minutes). + budget : float or None, optional + The deadline in seconds, the level's when ``None``. + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + cpus : sequence of int or None, optional + The allocation's CPUs, the process's affinity mask when ``None``. + progress : callable or None, optional + Called with a line of text as each stage starts. + + Returns + ------- + CalibrationReport + The allocation's own report; the nested allocations' are in its evidence + (``nested``). + + Raises + ------ + CalibrationError + For an unknown level or a budget that is not positive. + """ + if level not in LEVELS: + raise CalibrationError( + f"unknown calibration level {level!r}: quick, standard or thorough" + ) + chosen = LEVELS[level] + seconds = chosen.budget if budget is None else float(budget) + if not seconds > 0: + raise CalibrationError(f"a calibration's budget must be positive, got {budget}") + environ = dict(os.environ if environ is None else environ) + from solprop import calibration # noqa: PLC0415 - the package imports this module + + found = calibration.choice(environ | {"SOLPROP_CALIBRATION": "use"}) + if found.fingerprint is None: # pragma: no cover - "use" always fingerprints + raise CalibrationError("no fingerprint for this machine") + plan = _Plan( + level=chosen, + environ=environ, + store=_native.store.Store(calibration.store_directory(environ)), + digest=found.fingerprint.digest, + deadline=_Deadline(seconds), + say=progress or (lambda _: None), + ) + machine = _native.topology.read_topology() + held = tuple(_process.allowed_cpus() if cpus is None else cpus) + allocation = _native.allocation.classify(machine, held, _process.cgroup_cpus()) + if len(held) < 2: + return CalibrationReport( + level=chosen.name, + status="inconclusive", + fingerprint=plan.digest, + allocation=allocation.record(), + reason=SINGLE_CPU, + ) + plan.say(f"{chosen.name} calibration of {allocation.cores} cores") + reserve = seconds * chosen.reserve + report = _calibrate_allocation(plan, held, allocation, reserve) + # A nested allocation is its own row: a whole node too noisy to decide still + # gives each socket's. + if chosen.name == "quick" or report.status == "held": + return report + reports: list[dict[str, object]] = [] + for subset in nested(machine, held): + sub = _native.allocation.classify(machine, subset, None) + if plan.deadline.remaining() < seconds / 3: + reports.append( + { + "allocation": sub.record(), + "status": "skipped", + "reason": "too little of the budget left", + } + ) + continue + plan.say(f"nested allocation of {sub.cores} cores") + inner = _calibrate_allocation(plan, subset, sub, reserve / 2) + reports.append( + { + "allocation": sub.record(), + "status": inner.status, + "chosen": dict(inner.chosen), + "reason": inner.reason, + } + ) + return dataclasses.replace( + report, + evidence=dict(report.evidence) | {"nested": reports}, + seconds=plan.deadline.elapsed(), + ) + + +def _siblings(cpus: Sequence[int]) -> list[int]: + """Return the CPUs outside `cpus` that share a physical core with one of them. + + Parameters + ---------- + cpus : sequence of int + The allocation's CPUs. + + Returns + ------- + list of int + Their SMT siblings, ascending. + """ + machine = _native.topology.read_topology() + cores = {machine.core[cpu] for cpu in cpus if cpu in machine.core} + held = set(cpus) + return sorted( + cpu for cpu in machine.cpus if machine.core[cpu] in cores and cpu not in held + ) + + +def _native_budget(cpus: Sequence[int]) -> int: + """Return the built-in rule's width for an allocation of `cpus`, as native reads it. + + The smallest of the CPUs, their physical cores, the fastest kind's cores and the + control groups' quota (``thread_budget`` without ``SOLPROP_THREADS``). + + Parameters + ---------- + cpus : sequence of int + The allocation's CPUs; empty for the whole machine. + + Returns + ------- + int + Threads. + """ + limits = [len(cpus) or (os.cpu_count() or 1)] + for limit in ( + _process.physical_cores(cpus) if cpus else None, + _process.top_tier_cores(), + _process.cgroup_cpus(), + ): + if limit is not None: + limits.append(limit) + return max(1, min(limits)) diff --git a/src/solprop/calibration/first_use.py b/src/solprop/calibration/first_use.py new file mode 100644 index 000000000..c8ea3ca44 --- /dev/null +++ b/src/solprop/calibration/first_use.py @@ -0,0 +1,308 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Calibrating automatically on first use (SEP 46 sections 5 and 6.10). + +Under ``SOLPROP_CALIBRATION=auto``, the default, the first native workspace built in +an allocation with no row in its machine's profile first runs the quick calibration, +about 20 s, after a :class:`~solprop.exceptions.CalibrationNotice`. A process that +had not loaded the native extension yet then takes the whole new profile when it does; +one that had applies the new workspace settings that hold under the process settings +it started with, and the rest from its next process. Importing the native backend +never calibrates. + +A scan calibrates its workers' allocation at the standard level before it starts +them (:func:`for_workers`), unless a standard or thorough row already matches; a +worker never calibrates. Concurrent first runs on one host wait for the one that holds +the calibration claim, up to its deadline, then use its profile. A busy machine's +inconclusive calibration publishes nothing, and ``auto`` tries again after a day. +""" + +from __future__ import annotations + +import logging +import os +import sys +import time +import warnings +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING + +from solprop import _process +from solprop.calibration import _native +from solprop.exceptions import BackendError, CalibrationNotice + +if TYPE_CHECKING: + from solprop_native._allocation import ( # pyright: ignore[reportMissingImports] + AllocationClass, + ) + from solprop_native._profile import ( # pyright: ignore[reportMissingImports] + Choice, + ) + from solprop_native._store import ( # pyright: ignore[reportMissingImports] + Store, + ) + + from solprop.calibration.controller import CalibrationReport + +__all__ = ["RETRY_SECONDS", "ROLE", "ensure", "for_workers"] + +logger = logging.getLogger(__name__) + +ROLE = "SOLPROP_CALIBRATION_ROLE" +"""Set to ``worker`` in a scan's workers, which never calibrate.""" + +RETRY_SECONDS = 86400.0 +"""How long ``auto`` waits after an inconclusive calibration before trying again.""" + +_DONE: set[tuple[str, ...]] = set() + + +def _notice(level: str, seconds: float, store: object, cores: int) -> None: + """Issue the first-use notice as a warning and a log record. + + Parameters + ---------- + level : str + The level about to run. + seconds : float + Its budget. + store : object + The store's directory. + cores : int + The allocation's cores. + """ + message = ( + f"solprop is calibrating the native backend to this machine ({level}, about " + f"{seconds:.0f} s on {cores} cores; once per machine, allocation and release). " + f"The profile is kept in {store}. Run `solprop calibrate` for a fuller " + "calibration, or set SOLPROP_CALIBRATION=use to never measure, or =off to " + "ignore profiles." + ) + logger.info(message) + warnings.warn(message, CalibrationNotice, stacklevel=4) + + +def _device_build() -> bool: + """Return whether the installed native build runs on a device (phase 6's). + + Returns + ------- + bool + Whether its build record names a device backend. + """ + import json # noqa: PLC0415 + + for directory in _native.packages(): + try: + record = json.loads((directory / "_build.json").read_text(encoding="utf-8")) + except OSError, ValueError: + continue + return isinstance(record, dict) and record.get("device_backend") not in ( + None, + "none", + ) + return False + + +def _wait_for(store: Store, digest: str, key: str) -> bool: + """Wait for another process on this host calibrating this class; say if one was. + + Parameters + ---------- + store : solprop_native._store.Store + The user's store. + digest : str + The machine and build's key. + key : str + The allocation class's key. + + Returns + ------- + bool + Whether a claim was held, here or elsewhere: then this process does not + calibrate. + """ + import socket # noqa: PLC0415 + + holder = store.holder(digest, key) + if holder is None: + return False + if holder.host != socket.gethostname(): + return True + while store.holder(digest, key) is not None: + time.sleep(0.25) + return True + + +def _allowed(environ: Mapping[str, str]) -> bool: + """Return whether this process may measure implicitly. + + Parameters + ---------- + environ : Mapping[str, str] + The environment. + + Returns + ------- + bool + ``SOLPROP_CALIBRATION=auto``, not a scan's worker, and a CPU build. + """ + return ( + _native.store.mode(environ) == "auto" + and environ.get(ROLE) != "worker" + and not _device_build() + ) + + +def _covered(found: Choice, allocation: AllocationClass, need: str) -> bool: + """Return whether the profile already has the row an allocation needs. + + Parameters + ---------- + found : solprop_native._profile.Choice + What the stores hold for this machine and build. + allocation : solprop_native._allocation.AllocationClass + The allocation's class. + need : str + ``"any"``: any row it takes, its own or a nearer one's; ``"standard"``: its own + at the standard or thorough level. + + Returns + ------- + bool + Whether nothing needs measuring. + """ + from solprop.calibration.controller import ORDER # noqa: PLC0415 + + if need == "any" and found.found is not None: + return True + for row in () if found.generation is None else found.generation.rows: + try: + same = _native.allocation.AllocationClass.read(row.allocation) == allocation + except ValueError: + continue + if same and (need == "any" or ORDER.get(row.level, -1) >= ORDER["standard"]): + return True + return False + + +def _calibrate( + level: str, cpus: Sequence[int], environ: Mapping[str, str], *, need: str +) -> CalibrationReport | None: + """Calibrate `cpus`' allocation at `level` unless a row it needs exists. + + Parameters + ---------- + level : str + ``"quick"`` or ``"standard"``. + cpus : sequence of int + The allocation's CPUs. + environ : Mapping[str, str] + The environment. + need : str + ``"any"`` or ``"standard"`` (:func:`_covered`). + + Returns + ------- + CalibrationReport or None + The report, or ``None`` when nothing was measured. + """ + if len(cpus) < 2: + # One CPU has no team, waiting or grain to choose: a single-threaded scan's + # workers run on the built-in table without measuring (SEP 46 Part II). + return None + try: + _native.packages() + _native.store.mode(environ) + except BackendError, ImportError: + # No native backend, or one without the calibration's modules: selecting it + # fails, or runs on its own table, on its own terms. + return None + if not _allowed(environ): + return None + from solprop import calibration # noqa: PLC0415 - imports this module's package + from solprop.calibration.controller import LEVELS, calibrate # noqa: PLC0415 + + machine = _native.topology.read_topology() + allocation = _native.allocation.classify(machine, cpus, _process.cgroup_cpus()) + found = calibration.choice(environ) + assert found.fingerprint is not None # "auto" always fingerprints + digest = found.fingerprint.digest + store = _native.store.Store(calibration.store_directory(environ)) + key = (digest, allocation.key(), level) + try: + since = store.inconclusive_since(digest, allocation.key()) + if ( + key in _DONE + or _covered(found, allocation, need) + or (since is not None and time.time() - since < RETRY_SECONDS) + or _wait_for(store, digest, allocation.key()) + ): + return None + _DONE.add(key) + _notice(level, LEVELS[level].budget, store.root, allocation.cores) + return calibrate(level, environ=environ, cpus=cpus) + except OSError as error: + # The calibration is optional: a store that cannot be written (read-only, + # missing, full) leaves runs on the built-in table, with one warning. + _DONE.add(key) + message = ( + f"solprop could not use its calibration store at {store.root} ({error}); " + "the native backend runs on its built-in settings" + ) + logger.warning(message) + warnings.warn(message, CalibrationNotice, stacklevel=4) + return None + + +def ensure(environ: Mapping[str, str] | None = None) -> CalibrationReport | None: + """Calibrate quickly before a first native workspace, if this allocation needs it. + + A no-op unless ``SOLPROP_CALIBRATION=auto``, in a process that is not a scan's + worker, on a CPU build, with no row for its allocation, no inconclusive attempt in + the last day and nobody else calibrating it; once per process. A process that has + already loaded the native extension then refreshes its profile. + + Parameters + ---------- + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + solprop.calibration.controller.CalibrationReport or None + The quick calibration's report, or ``None`` when none ran. + """ + environ = os.environ if environ is None else environ + report = _calibrate("quick", _process.allowed_cpus(), environ, need="any") + if report is not None and "solprop_native" in sys.modules: + sys.modules["solprop_native"].refresh_profile() + return report + + +def for_workers( + cpus: Sequence[int], environ: Mapping[str, str] | None = None +) -> CalibrationReport | None: + """Calibrate a scan's workers' allocation at the standard level, if it needs it. + + Run by the scan's parent before its workers start, in children pinned like one + worker; the other partitions stay idle meanwhile, so caches and memory bandwidth + are less loaded than in the scan, which the row's evidence records. + + Parameters + ---------- + cpus : sequence of int + One worker's CPUs. + environ : Mapping[str, str] or None, optional + The environment; the process's when ``None``. + + Returns + ------- + solprop.calibration.controller.CalibrationReport or None + The report, or ``None`` when a standard or thorough row already matches or + nothing may be measured. + """ + environ = os.environ if environ is None else environ + return _calibrate("standard", cpus, environ, need="standard") diff --git a/src/solprop/calibration/grain.py b/src/solprop/calibration/grain.py new file mode 100644 index 000000000..724b0cc70 --- /dev/null +++ b/src/solprop/calibration/grain.py @@ -0,0 +1,364 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Operation-grain candidates from the work sizes the calibration problems run (SEP 46). + +Each CPU operation class threads by its work: serially below its threshold, on the +medium width below its full size, on its pool's capacity from there +(``_execution.ExecutionRecord``). A workspace's ``execution_sizing`` records every +operation it runs, with its class, work and width. A threshold or full size matters +only where it falls between two work sizes the problems actually run, so the +candidates of a class are its one-step moves past the nearest of them: thread one +more size, serialise one more, widen one more to the full pool, narrow one more to +the medium width. At most four a class (section 6.5); a candidate that changes no +operation's width is left out. The medium width and the compact team take a short +ladder of their own. Each candidate is then timed on the problems it changes, and +§6.5's rule decides (:mod:`solprop.calibration.selection`). + +Every function here is pure. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from solprop.calibration.selection import Verdict + +__all__ = [ + "TIMED", + "Candidate", + "Operation", + "Record", + "affected", + "best", + "operations", + "proposals", + "width", +] + +type Record = Mapping[str, int | bool] +"""A native execution record (``ExecutionRecord.native()``).""" + +TIMED = ("stage", "response", "raman", "copy", "dense", "modal") +"""The classes whose thresholds the grain moves, in the order their candidates go. + +The exponential Adams trial's passes and a coupled flow's matrix functions run in +no calibration window: their thresholds keep the built-in table's. Nor are the +transforms' moved: their thresholds count elements, and transforms of the same count +thread oppositely by shape. On AMD EPYC 7763 and 9B45 cores, a full width from 98304 +elements won 0.56 on a line's many short rows and ran ``grin_nonlinear``'s few long +rows 8-10 % slower (SEP 46 Part II), so they keep the table until threading keys on +rows and row length. +""" + + +@dataclass(frozen=True) +class Operation: + """One operation a problem runs, as its workspace's sizing records it. + + Attributes + ---------- + pool : str + ``"kernels"``, ``"fft"``, ``"raman_fft"`` or ``"raman_tail_fft"``. + kind : str + Its class: ``"stage"``, ``"response"``, ``"dense"``, ``"fft"``, ``"raman"``, + ``"copy"``, ``"flow"``, ``"modal"`` or ``"adams"``. + elements : int + Its work. + samples : int + A response's fine samples per column; zero otherwise. + """ + + pool: str + kind: str + elements: int + samples: int = 0 + + +def operations(sizing: Mapping[str, object]) -> list[Operation]: + """Return the operations a workspace's ``execution_sizing`` records. + + Parameters + ---------- + sizing : Mapping[str, object] + The sizing, keyed ``pool.operation.elements[.samples.N]``. + + Returns + ------- + list of Operation + One per key, in key order; keys of another form are left out. + + Examples + -------- + >>> found = operations({"kernels.stage.4096": {}, "fft.fft.66.samples.33": {}}) + >>> [(operation.kind, operation.elements, operation.samples) for operation in found] + [('fft', 66, 33), ('stage', 4096, 0)] + """ + found: list[Operation] = [] + for key in sorted(sizing): + parts = key.split(".") + if len(parts) == 3 and parts[2].isdigit(): + found.append(Operation(parts[0], parts[1], int(parts[2]))) + elif len(parts) == 5 and parts[3] == "samples" and parts[2].isdigit(): + found.append(Operation(parts[0], parts[1], int(parts[2]), int(parts[4]))) + return found + + +def width(record: Record, operation: Operation, capacity: int) -> int: + """Return the width `record` gives `operation` on a pool of `capacity` threads. + + The native rule (``operation_threads``), without the compact cap, the stable team + or an explicit pool: one below the threshold, the full pool for a long or wide + response, the medium width below the full size, the full pool from it. + + Parameters + ---------- + record : Mapping[str, int or bool] + A native execution record. + operation : Operation + The operation. + capacity : int + The pool's width. + + Returns + ------- + int + Threads. + + Examples + -------- + >>> table = {"stage_threshold": 100, "stage_full": 1000, "medium_threads": 4} + >>> [width(table, Operation("kernels", "stage", n), 18) for n in (50, 500, 5000)] + [1, 4, 18] + """ + threshold = int(record[f"{operation.kind}_threshold"]) + full = int(record[f"{operation.kind}_full"]) + if operation.elements < threshold: + return 1 + if ( + operation.kind == "response" + and operation.samples + and ( + operation.samples >= int(record["response_full_samples"]) + or operation.elements // operation.samples + >= int(record["response_full_columns"]) + ) + ): + return capacity + if operation.elements < full: + return min(capacity, int(record["medium_threads"])) + return capacity + + +def affected( + record: Record, base: Record, found: Iterable[Operation], capacity: int +) -> bool: + """Return whether `record` gives any of `found` another width than `base` does. + + Parameters + ---------- + record, base : Mapping[str, int or bool] + Two native execution records. + found : iterable of Operation + A problem's operations. + capacity : int + The pool's width. + + Returns + ------- + bool + Whether timing the problem can tell the two apart. + """ + return any( + width(record, operation, capacity) != width(base, operation, capacity) + for operation in found + ) + + +@dataclass(frozen=True) +class Candidate: + """One grain candidate: a setting's group and the record's changed fields. + + Attributes + ---------- + setting : str + What it moves: an operation class, ``"medium"`` or ``"compact"``. + name : str + A short description, such as ``"stage threshold 2048"``. + changes : Mapping[str, int] + The fields it sets over the base record. + """ + + setting: str + name: str + changes: Mapping[str, int] + + def record(self, base: Record) -> dict[str, int | bool]: + """Return `base` with this candidate's changes. + + Parameters + ---------- + base : Mapping[str, int or bool] + A native execution record. + + Returns + ------- + dict + The candidate's record. + """ + return {**base, **self.changes} + + +def _class_moves( + kind: str, sizes: list[int], record: Record, *, fulls: bool +) -> list[Candidate]: + """Return a class's one-step moves past its nearest work sizes. + + Parameters + ---------- + kind : str + The operation class. + sizes : list of int + Its work sizes in the problems, ascending. + record : Mapping[str, int or bool] + The base record. + fulls : bool + Whether to move its full size as well as its threshold. + + Returns + ------- + list of Candidate + At most four. + """ + threshold = int(record[f"{kind}_threshold"]) + full = int(record[f"{kind}_full"]) + serial = [size for size in sizes if size < threshold] + threaded = [size for size in sizes if size >= threshold] + medium = [size for size in threaded if size < full] + whole = [size for size in threaded if size >= full] + moves: list[tuple[str, int, int]] = [] + if serial: + moves.append(("threshold", max(serial), max(full, *serial))) + if threaded: + lowered = min(threaded) + 1 + moves.append(("threshold", lowered, max(full, lowered))) + if fulls and medium: + moves.append(("full", threshold, max(medium))) + if fulls and whole and min(whole) + 1 > threshold: + moves.append(("full", threshold, min(whole) + 1)) + # Each move puts a different size on the other side of a boundary, so no two are + # alike. + return [ + Candidate( + kind, + f"{kind} {field} {new_threshold if field == 'threshold' else new_full}", + {f"{kind}_threshold": new_threshold, f"{kind}_full": new_full}, + ) + for field, new_threshold, new_full in moves + ] + + +def proposals( + record: Record, + problems: Mapping[str, list[Operation]], + capacity: int, + compact: Iterable[str] = (), + *, + limit: int | None = None, + fulls: bool = True, +) -> list[Candidate]: + """Return the grain candidates for problems running `problems`' operations. + + Parameters + ---------- + record : Mapping[str, int or bool] + The base record: the process's, under which the problems were sized. + problems : Mapping[str, list of Operation] + Each problem's operations. + capacity : int + The pool's width. + compact : iterable of str + The problems that run on a compact team. + limit : int or None, optional + The most candidates; the medium widths and compact teams come first, then + the classes in :data:`TIMED` order. + fulls : bool, optional + Whether to move the classes' full sizes as well as their thresholds. + + Returns + ------- + list of Candidate + The medium widths, the compact teams, then each timed class's moves; each + changes some operation's width (or a compact problem's team). + + Examples + -------- + >>> base = {"stage_threshold": 100, "stage_full": 1000, "medium_threads": 4} + >>> found = proposals(base, {"p": [Operation("kernels", "stage", 500)]}, 18) + >>> [candidate.name for candidate in found] + ['medium 2', 'medium 8', 'stage threshold 501', 'stage full 500'] + """ + sizes: dict[str, set[int]] = {} + for found in problems.values(): + for operation in found: + sizes.setdefault(operation.kind, set()).add(operation.elements) + candidates: list[Candidate] = [] + medium = int(record["medium_threads"]) + for value in (2, 8): + if value != medium and value < capacity: + candidate = Candidate( + "medium", f"medium {value}", {"medium_threads": value} + ) + if any( + affected(candidate.record(record), record, found, capacity) + for found in problems.values() + ): + candidates.append(candidate) + teams = list(compact) + current = int(record.get("compact_threads", capacity)) + if teams: + for value in (4, 16): + if ( + value != current + and value <= capacity + and min(value, capacity) != min(current, capacity) + ): + candidates.append( + Candidate("compact", f"compact {value}", {"compact_threads": value}) + ) + for kind in TIMED: + if kind in sizes and f"{kind}_threshold" in record: + candidates += _class_moves(kind, sorted(sizes[kind]), record, fulls=fulls) + return candidates if limit is None else candidates[:limit] + + +def best( + candidates: Iterable[Candidate], verdicts: Mapping[str, Verdict] +) -> list[Candidate]: + """Return each setting's admitted candidate with the lowest score. + + Parameters + ---------- + candidates : iterable of Candidate + The candidates timed. + verdicts : Mapping[str, Verdict] + Each one's verdict, by name. + + Returns + ------- + list of Candidate + At most one a setting, in the order the settings first appear. + """ + chosen: dict[str, tuple[float, Candidate]] = {} + for candidate in candidates: + verdict = verdicts[candidate.name] + if not verdict.admitted or verdict.score is None: + continue + held = chosen.get(candidate.setting) + if held is None or verdict.score < held[0]: + chosen[candidate.setting] = (verdict.score, candidate) + return [candidate for _, candidate in chosen.values()] diff --git a/src/solprop/calibration/problems.py b/src/solprop/calibration/problems.py new file mode 100644 index 000000000..43aa0953e --- /dev/null +++ b/src/solprop/calibration/problems.py @@ -0,0 +1,269 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The calibration's synthetic problems (SEP 46 section 6.3). + +Five problems that solprop ships, so that a calibration never needs a user's +simulation: a GNLSE with Raman response and self-steepening, a four-mode helium +capillary with plasma, a mode-averaged hydrogen capillary with Raman response, a +radial argon filament with plasma and a one-dimensional free-space line. Together they +exercise every CPU operation class: stage passes, responses with short and long +columns, modal and dense products, Raman histories, plasma columns, radial and +temporal transforms. Each is adapted from the benchmark tracking set's case of the +same physics (``benchmarks/builders.py``), with no noise so that every candidate does +the same work, and with a *scale* that sizes its grid: the time window for the +one-dimensional problems and the transverse count for the spatial ones. A run times +their trial steps (``StepDriver.timed_step``), never a propagation. + +Four of them also come at production size (``*_large``), as large as the tracking +set's cases of their kind: on these alone a narrower team or a higher threshold that +wins on the small problems shows what it costs a production run. On 16 cores of a +Google Axion, a team of 8 won the small problems by 16 % and ran the tracking set's +large cases up to 1.8 times slower (SEP 46 Part II). +""" + +from __future__ import annotations + +import dataclasses +import math +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from solprop.driver import Simulation + +__all__ = ["PROBLEMS", "build"] + + +def _gnlse(scale: float) -> Simulation: + """Build Dudley, Genty and Coen's soliton fission on a `scale` x 3 ps window. + + The dispersion coefficients follow J. M. Dudley, G. Genty and S. Coen, Rev. Mod. + Phys. 78, 1135 (2006), doi:10.1103/RevModPhys.78.1135, section III, as the + benchmark case does. + + Parameters + ---------- + scale : float + The window's size relative to the standard one. + + Returns + ------- + solprop.driver.Simulation + The simulation. + """ + from solprop.driver import Simulation # noqa: PLC0415 - imports the physics + + return Simulation.from_gnlse( + 0.11, + 0.15, + [ + 0.0, + 0.0, + -11.830e-27, + 8.1038e-41, + -9.5205e-56, + 2.0737e-70, + -5.3943e-85, + 1.3486e-99, + -2.5495e-114, + 3.0524e-129, + -1.7140e-144, + ], + wavelength=835e-9, + fwhm=50e-15, + shape="sech", + peak_power=10e3, + raman=True, + shock=True, + noise=None, + backend="native", + wavelength_limits=(400e-9, 2e-6), + trange=3e-12 * scale, + ) + + +def _capillary(scale: float) -> Simulation: + """Build four modes of a helium capillary with plasma, on `scale` x 200 fs. + + Parameters + ---------- + scale : float + The window's size relative to the standard one. + + Returns + ------- + solprop.driver.Simulation + The simulation. + """ + from solprop.driver import Simulation # noqa: PLC0415 - imports the physics + + return Simulation.from_capillary( + 125e-6, + 1.5, + "He", + (0.8, 0.0), + wavelength=800e-9, + fwhm=7.5e-15, + energy=275e-6, + modes=4, + noise=None, + backend="native", + wavelength_limits=(90e-9, 4e-6), + trange=200e-15 * scale, + ) + + +def _averaged(scale: float) -> Simulation: + """Build a mode-averaged hydrogen capillary with Raman, on `scale` x 800 fs. + + Parameters + ---------- + scale : float + The window's size relative to the standard one. + + Returns + ------- + solprop.driver.Simulation + The simulation. + """ + from solprop.driver import Simulation # noqa: PLC0415 - imports the physics + + return Simulation.from_capillary( + 20e-6, + 0.4, + "H2", + 10.0, + wavelength=1030e-9, + fwhm=40e-15, + energy=3e-6, + loss=False, + plasma=False, + modes=1, + mode_average=True, + noise=None, + backend="native", + wavelength_limits=(200e-9, 4e-6), + trange=800e-15 * scale, + ) + + +def _spatial(kind: str, scale: float) -> Simulation: + """Build an argon filament on a radial grid, or Kerr focusing on a line. + + Parameters + ---------- + kind : str + ``"radial"`` or ``"line"``. + scale : float + The transverse count relative to the standard one. + + Returns + ------- + solprop.driver.Simulation + The simulation. + """ + import numpy as np # noqa: PLC0415 + + from solprop.constants import C # noqa: PLC0415 + from solprop.driver import Simulation, SimulationSettings # noqa: PLC0415 + from solprop.grid import FieldGrid, LineGrid, RadialGrid # noqa: PLC0415 + from solprop.nonlinear.optmat_rates import OptmatRate # noqa: PLC0415 + from solprop.nonlinear.responses import ( # noqa: PLC0415 + KerrResponse, + PlasmaResponse, + ) + from solprop.physdata import gamma3 # noqa: PLC0415 + from solprop.pulses import GaussianPulse, SpatialPulse # noqa: PLC0415 + from solprop.spatial import SpatialMedium, critical_power # noqa: PLC0415 + + radial = kind == "radial" + length = 0.01 + grid = FieldGrid.from_limits( + length, + 800e-9, + (350e-9, 2e-6) if radial else (500e-9, 1.6e-6), + 150e-15, + ) + medium = SpatialMedium("Ar", 1.0) + chi3 = gamma3("Ar", wavelength=800e-9) + density = float(medium.density(np.array([0.0]))[0]) + carrier = 2 * math.pi * C / 800e-9 + index = float(np.real(medium.index(np.array([carrier]))[0])) + power = (3.0 if radial else 3000.0) * critical_power( + 800e-9, chi3 * density, index=index + ) + energy = power * 30e-15 * math.sqrt(math.pi / (4 * math.log(2))) + pulse = SpatialPulse( + GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), 100e-6 + ) + responses: list[KerrResponse | PlasmaResponse] = [KerrResponse(chi3, thg=False)] + settings = SimulationSettings( + save_planes=[length], + dz_max=length, + dz_init=length * 1e-4, + auto_init=True, + backend="native", + ) + if radial: + responses.append(PlasmaResponse("Ar", OptmatRate("Ar", 800e-9))) + settings = dataclasses.replace(settings, ionisation_field_range=(1e7, 1e11)) + return Simulation.from_radial( + grid, + RadialGrid(1.5e-3, max(16, 2 * round(32 * scale))), + medium, + responses, + pulse, + settings=settings, + noise=None, + ) + return Simulation.from_free( + grid, + LineGrid(1e-3, max(32, round(128 * scale))), + medium, + responses, + pulse, + settings=settings, + ) + + +PROBLEMS: dict[str, Callable[[float], Simulation]] = { + "gnlse": _gnlse, + "capillary": _capillary, + "averaged": _averaged, + "radial": lambda scale: _spatial("radial", scale), + "line": lambda scale: _spatial("line", scale), + # Production sizes: 16384 samples (the Raman GNLSE case), four modes on 5120 + # samples (the resonant dispersive wave case), 384 radial nodes (the filament + # case), and 4096 columns standing in for the Kerr beams' many columns. + "gnlse_large": lambda scale: _gnlse(4.0 * scale), + "capillary_large": lambda scale: _capillary(2.0 * scale), + "radial_large": lambda scale: _spatial("radial", 6.0 * scale), + "line_large": lambda scale: _spatial("line", 32.0 * scale), +} +"""The calibration problems, each built at a scale (1 is the standard size).""" + + +def build(name: str, scale: float = 1.0) -> Simulation: + """Build one calibration problem at a scale of its standard size. + + Parameters + ---------- + name : str + One of :data:`PROBLEMS`. + scale : float, optional + Its grid's size relative to the standard one: the time window of the + one-dimensional problems, the transverse count of the spatial ones. + + Returns + ------- + solprop.driver.Simulation + The simulation, on the native backend, without noise. + + Raises + ------ + KeyError + For an unknown name. + """ + return PROBLEMS[name](scale) diff --git a/src/solprop/calibration/refine.py b/src/solprop/calibration/refine.py new file mode 100644 index 000000000..6556be177 --- /dev/null +++ b/src/solprop/calibration/refine.py @@ -0,0 +1,498 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Stage 2: refine the workspace settings for one simulation (SEP 46 section 6.6). + +The machine profile fits the native backend to the machine; a costly campaign can +then fit it to its own simulation. :func:`refine` builds fresh workspaces from the +simulation's translated problem in the calling process, under the process's record +and under each candidate's, and never touches the simulation's state, output or +callbacks. The candidates are the operation-grain moves of the simulation's own work +sizes (:mod:`solprop.calibration.grain`), each timed by trial steps of the +simulation's own operators at positions spread along its length, interleaved with the +base record and admitted by §6.5's rule; the winners are validated together. Speed +alone never admits a candidate: every candidate's trial steps must end finite and +within ten times the run's relative tolerance of the base's (section 12 item 4). + +The result is stored under the simulation's execution descriptor +(``_profile.descriptor``), and every later run or scan point with an equal descriptor +applies it while the machine profile it refined still holds. +""" + +from __future__ import annotations + +import dataclasses +import math +import random +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np + +from solprop.calibration import _native, grain, selection +from solprop.exceptions import CalibrationError + +if TYPE_CHECKING: + from solprop.driver import Simulation + +__all__ = ["ACCURACY", "POSITIONS", "ROUNDS", "RefinementReport", "refine"] + +ROUNDS = 5 +"""Interleaved rounds per comparison, as at the standard level.""" + +POSITIONS = 3 +"""Trial-step windows along the simulation's length: its start, a third and two.""" + +ACCURACY = 10.0 +"""How many times the run's relative tolerance a candidate's fields may differ by.""" + + +@dataclass(frozen=True) +class RefinementReport: + """What a refinement measured and kept. + + Attributes + ---------- + status : str + ``"published"`` (a refinement was stored), ``"kept"`` (the machine profile + stays: no candidate won and was accurate), or ``"inconclusive"`` (too noisy, + too little time, or no machine profile to refine). + descriptor : str + The simulation's execution descriptor. + changes : Mapping[str, object] + The workspace settings stored over the process's record. + evidence : Mapping[str, object] + The noise, each candidate's verdict and accuracy, and the validation. + reason : str + Why. + seconds : float + The wall time taken. + path : pathlib.Path or None + The refinement file, if one was stored. + """ + + status: Literal["published", "kept", "inconclusive"] + descriptor: str + changes: Mapping[str, object] = field(default_factory=dict[str, object]) + evidence: Mapping[str, object] = field(default_factory=dict[str, object]) + reason: str = "" + seconds: float = 0.0 + path: Path | None = None + + def describe(self) -> str: + """Return the report as lines for a terminal. + + Returns + ------- + str + The status, reason, changes and file. + """ + lines = [ + f"refinement: {self.status} in {self.seconds:.1f} s ({self.descriptor})", + f" {self.reason}", + ] + lines += [f" {name}: {value}" for name, value in self.changes.items()] + if self.path is not None: + lines.append(f" refinement: {self.path}") + return "\n".join(lines) + "\n" + + +class _Windows: + """One record's backend on the simulation, and its trial steps along it. + + Parameters + ---------- + simulation : solprop.driver.Simulation + The simulation. + record : Mapping[str, int or bool] or None + A native execution record, or ``None`` for the process's own. + """ + + def __init__( + self, simulation: Simulation, record: Mapping[str, int | bool] | None + ) -> None: + from solprop_native._execution import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + ExecutionRecord, + ) + from solprop_native.backend import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + NativeBackend, + ) + + problem = simulation.translate() + self.backend = NativeBackend( + problem, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + cpu_execution=None + if record is None + else ExecutionRecord.from_native(record), + ) + self.backend.set_initial(problem.initial.field0, None, z=problem.initial.z) + start = float(problem.initial.z) + end = float(max(problem.stepper.save_planes, default=start)) + self.dz = float(problem.stepper.dz_init) + self.positions = ( + [start] + if problem.stepper.schedule is not None + else [ + start + (end - start) * index / POSITIONS for index in range(POSITIONS) + ] + ) + self.low_storage = problem.stepper.schedule is not None + + def fields(self) -> list[np.ndarray]: + """Return the field each window's trial step ends in, on the host. + + A low-storage march advances its state with each step, so it has no fields + to compare and returns none. + + Returns + ------- + list of numpy.ndarray + One per window. + """ + found: list[np.ndarray] = [] + for z in self.positions: + if self.low_storage: + return [] + _, state = self.backend._trial_step(z, self.dz) # pyright: ignore[reportPrivateUsage] + found.append(np.asarray(self.backend._observed_field(state))) # pyright: ignore[reportPrivateUsage] + return found + + def time(self, repeats: int) -> float: + """Return the seconds per trial step over the windows, after a warm-up. + + Parameters + ---------- + repeats : int + Trial steps timed in each window. + + Returns + ------- + float + Seconds per step. + """ + steps: list[Callable[[], object]] = ( + [self.backend.timed_step()] + if self.low_storage + else [ + (lambda z=z: self.backend._trial_step(z, self.dz)) # pyright: ignore[reportPrivateUsage] + for z in self.positions + ] + ) + for step in steps: + step() + step() + begun = time.perf_counter() + for step in steps: + for _ in range(repeats): + step() + return (time.perf_counter() - begun) / (repeats * len(steps)) + + def close(self) -> None: + """Close the backend.""" + self.backend.close() + + +def _accurate( + found: list[np.ndarray], reference: list[np.ndarray], rtol: float +) -> bool: + """Return whether `found` is finite and within ``ACCURACY * rtol`` of `reference`. + + Parameters + ---------- + found, reference : list of numpy.ndarray + The windows' trial-step fields under a candidate and under the base. + rtol : float + The run's relative tolerance. + + Returns + ------- + bool + Whether every window agrees, in the relative L2 norm. + """ + for mine, theirs in zip(found, reference, strict=True): + if not np.all(np.isfinite(mine)): + return False + scale = float(np.linalg.norm(theirs)) or 1.0 + if float(np.linalg.norm(mine - theirs)) > ACCURACY * rtol * scale: + return False + return True + + +def refine( + simulation: Simulation, + budget: float = 60.0, + *, + progress: Callable[[str], object] | None = None, +) -> RefinementReport: + """Refine the native workspace settings for `simulation` (SEP 46 stage 2). + + Parameters + ---------- + simulation : solprop.driver.Simulation + The simulation; it is translated if it is not already, and otherwise left + alone. + budget : float, optional + The deadline, in seconds. + progress : callable or None, optional + Called with a line of text as each stage starts. + + Returns + ------- + RefinementReport + What was kept and whether it was stored. + + Raises + ------ + CalibrationError + For a budget that is not positive, or a simulation not on the native backend. + """ + started = time.monotonic() + report = _refine(simulation, budget, progress) + return dataclasses.replace(report, seconds=time.monotonic() - started) + + +def _candidates( + problem: object, + sizing: Mapping[str, object], + base: Mapping[str, int | bool], + threads: int, +) -> list[grain.Candidate]: + """Return the grain candidates that change this simulation's widths. + + Parameters + ---------- + problem : solprop.translate.problem.KernelProblem + The translated problem, for its shape. + sizing : Mapping[str, object] + Its workspace's ``execution_sizing`` under the base record. + base : Mapping[str, int or bool] + The process's record. + threads : int + The pool's width. + + Returns + ------- + list of solprop.calibration.grain.Candidate + At most twelve. + """ + shape = cast(tuple[int, ...], getattr(problem, "shape")) # noqa: B009 - typed loosely + operations = grain.operations(sizing) + compact = ["simulation"] if math.prod(shape[:-1]) == 1 else [] + return [ + candidate + for candidate in grain.proposals( + base, {"simulation": operations}, threads, compact, limit=12 + ) + if candidate.setting == "compact" + or grain.affected(candidate.record(base), base, operations, threads) + ] + + +def _rounds( + base: _Windows, windows: Mapping[str, _Windows], repeats: int, until: float +) -> dict[str, list[float | None]]: + """Time interleaved rounds of every candidate's windows against the base's. + + Parameters + ---------- + base : _Windows + The process's record's windows. + windows : Mapping[str, _Windows] + Each candidate's. + repeats : int + Trial steps per window. + until : float + The monotonic time after which no round starts. + + Returns + ------- + dict of str to list + ``again``, the base's A/A ratio, and each candidate's ratio to the base, per + round. + """ + generator = random.Random(5000) + ratios: dict[str, list[float | None]] = {"again": []} + ratios |= {name: [] for name in windows} + for _ in range(ROUNDS): + if time.monotonic() > until: + break + slots = [("base", base), ("again", base), *windows.items()] + generator.shuffle(slots) + seconds = {name: window.time(repeats) for name, window in slots} + for name in ratios: + ratios[name].append(seconds[name] / seconds["base"]) + return ratios + + +def _together( + combined: _Windows, base: _Windows, repeats: int, noise: float +) -> selection.Validation: + """Validate the admitted settings together against the base on fresh rounds. + + Parameters + ---------- + combined, base : _Windows + The combination's windows and the base's. + repeats : int + Trial steps per window. + noise : float + The session's A/A noise. + + Returns + ------- + solprop.calibration.selection.Validation + The verdict. + """ + ratios: list[float | None] = [] + for index in range(ROUNDS): + pair = [("combined", combined), ("base", base)] + if index % 2: + pair.reverse() + timed = {name: window.time(repeats) for name, window in pair} + ratios.append(timed["combined"] / timed["base"]) + return selection.validate( + {"simulation": (ratios, [1.0] * len(ratios))}, noise, ROUNDS + ) + + +def _refine( + simulation: Simulation, + budget: float, + progress: Callable[[str], object] | None, +) -> RefinementReport: + """Carry out :func:`refine`; its report's seconds are set by the caller. + + Parameters + ---------- + simulation : solprop.driver.Simulation + The simulation. + budget : float + The deadline, in seconds. + progress : callable or None + Called with a line of text as each stage starts. + + Returns + ------- + RefinementReport + The report. + + Raises + ------ + CalibrationError + For a budget that is not positive, or a simulation not on the native backend. + """ + if not budget > 0: + raise CalibrationError(f"a refinement's budget must be positive, got {budget}") + if simulation.settings.backend not in ("native", "auto"): + raise CalibrationError( + "a refinement fits the native backend; this simulation runs on " + f"{simulation.settings.backend!r}" + ) + say = progress or (lambda _: None) + deadline = time.monotonic() + budget + import solprop_native as native # noqa: PLC0415 # pyright: ignore[reportMissingImports] + + if native.PROFILE.fingerprint is None: + return RefinementReport( + "inconclusive", "", reason="SOLPROP_CALIBRATION=off: no profile to refine" + ) + problem = simulation.translate() + base_record = cast(dict[str, int | bool], native.EXECUTION.native()) + opened = [_Windows(simulation, None)] + base = opened[0] + try: + sizing = cast(dict[str, object], base.backend.metadata()["execution_sizing"]) + key = native._profile.descriptor( # pyright: ignore[reportPrivateUsage] + shape=problem.shape, + grid_kind=problem.grid_kind, + integrator=type(problem.stepper.integrator).__name__, + sizing=sizing, + process=native.PROCESS, + fingerprint=native.PROFILE.fingerprint.digest, + ) + candidates = _candidates(problem, sizing, base_record, native.PROCESS.threads) + evidence: dict[str, object] = {"candidates": [c.name for c in candidates]} + if not candidates: + return RefinementReport( + "kept", key, evidence=evidence, reason="no candidate changes a width" + ) + say(f"building {len(candidates)} candidate workspaces") + windows = { + c.name: _Windows(simulation, c.record(base_record)) for c in candidates + } + opened += list(windows.values()) + rtol = float(problem.stepper.controller.rtol) + reference = base.fields() + accurate = { + name: _accurate(window.fields(), reference, rtol) + for name, window in windows.items() + } + repeats = max(2, min(500, round(0.1 / max(base.time(1), 1e-6)))) + say("timing interleaved rounds") + ratios = _rounds(base, windows, repeats, deadline - 0.2 * budget) + ones = [1.0] * len(ratios["again"]) + noise = selection.noise(ones, ratios["again"], ROUNDS) + if noise is None or noise > selection.NOISE_LIMIT: + return RefinementReport( + "inconclusive", + key, + evidence=evidence | {"noise": noise}, + reason="too noisy or too little time to decide", + ) + verdicts = { + name: selection.admit(ratios[name], ones, noise, ROUNDS) for name in windows + } + evidence |= { + "noise": noise, + "accurate": accurate, + "verdicts": {name: verdict.record() for name, verdict in verdicts.items()}, + } + best = grain.best([c for c in candidates if accurate[c.name]], verdicts) + changes: dict[str, int | bool] = {} + for candidate in best: + changes |= candidate.changes + reason = "no accurate candidate won" + if best: + say(f"validating {len(best)} settings together") + combined = _Windows(simulation, {**base_record, **changes}) + opened.append(combined) + validation = _together(combined, base, repeats, noise) + evidence["validation"] = validation.record() + reason = ( + "the combination is inaccurate" + if not _accurate(combined.fields(), reference, rtol) + else "the combination's validation failed" + if not validation.passed + else "" + ) + if reason: + return RefinementReport("kept", key, evidence=evidence, reason=reason) + store = _native.store.Store(_native.store.cache_directory()) + path = store.publish_refinement( + native.PROFILE.fingerprint.digest, + key, + { + "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "base": base_record, + "workspace": changes, + "evidence": evidence, + }, + ) + return RefinementReport( + "published", + key, + changes=changes, + evidence=evidence, + reason="admitted: " + ", ".join(candidate.name for candidate in best), + path=path, + ) + finally: + for window in opened: + window.close() diff --git a/src/solprop/calibration/selection.py b/src/solprop/calibration/selection.py new file mode 100644 index 000000000..f4ff0814d --- /dev/null +++ b/src/solprop/calibration/selection.py @@ -0,0 +1,421 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The calibration's statistical rule: which setting replaces the table (SEP 46). + +One rule throughout (SEP 46 section 6.5), the ``ab`` harness's: + +- *Pairs.* Each candidate is timed interleaved with the built-in value, in rounds; a + round times each once in a fresh order. A candidate's score is the median of its + per-round ratios to the built-in. +- *Noise.* Each session also times the built-in against itself (A/A). The noise is the + largest per-round deviation of that ratio from one, and at least + :data:`NOISE_FLOOR`. +- *Admission.* A candidate replaces the built-in value only if its largest per-round + ratio is below one minus the noise: it wins in every round by more than the noise. + And no problem may be slower beyond the noise and :data:`TOLERANCE`: its median + ratio on each problem timed must stay within + ``1 + noise + TOLERANCE``, so that a large win on some problems cannot carry a real + loss on another, while a loss within the bar does not veto a broad win. Ties keep + the built-in. A failed round is dropped; fewer than the level's minimum rounds + keeps the built-in. +- *Combined validation.* The admitted settings are timed together against the table on + fresh rounds; the combination is published only if no window is slower than that + same bar and the geometric mean is below one. Otherwise groups are dropped in + :data:`DROP_ORDER` and the remainder validated again. + +Every function here is pure: it reads timings and returns a verdict. +""" + +from __future__ import annotations + +import math +import statistics +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field + +__all__ = [ + "DROP_ORDER", + "MAX_CANDIDATES", + "NOISE_FLOOR", + "NOISE_LIMIT", + "TOLERANCE", + "Times", + "Validation", + "Verdict", + "admit", + "choose", + "fallbacks", + "noise", + "ratios", + "validate", +] + +NOISE_FLOOR = 0.02 +"""The least noise a session claims: a win must exceed 2 % in every round.""" + +TOLERANCE = 0.05 +"""How much slower than its noise any one problem may run under a candidate, so that a +small loss on one problem does not veto a broad win (the maintainer's choice; SEP 46 +section 7 item 1 asked for 2 %).""" + +NOISE_LIMIT = 0.10 +"""A/A noise above which a session is inconclusive and nothing is published.""" + +MAX_CANDIDATES = 4 +"""The most candidates a setting is chosen from, so that the search cannot select noise +from a large set.""" + +DROP_ORDER = ("grain", "fft", "process") +"""The groups dropped, in this order, when a combination fails validation: operation +grain, FFT planning, then the waiting policy and the team.""" + +type Times = Sequence[float | None] +"""Seconds per round, ``None`` for a round that failed or was skipped.""" + + +def ratios(candidate: Times, builtin: Times) -> list[float]: + """Return the per-round ratios of `candidate` to `builtin`, dropping failed rounds. + + Parameters + ---------- + candidate, builtin : sequence of float or None + Seconds per round, in round order. + + Returns + ------- + list of float + One ratio per round where both were timed. + + Examples + -------- + >>> ratios([0.9, None, 1.8], [1.0, 1.0, 2.0]) + [0.9, 0.9] + """ + return [ + new / old + for new, old in zip(candidate, builtin, strict=True) + if new is not None and old is not None and old > 0 and math.isfinite(new) + ] + + +def noise(builtin: Times, again: Times, minimum: int) -> float | None: + """Return the A/A noise of a session, or ``None`` with too few rounds. + + Parameters + ---------- + builtin, again : sequence of float or None + The built-in setting timed twice in each round. + minimum : int + The fewest complete rounds that make a session. + + Returns + ------- + float or None + The largest per-round deviation of the ratio from one, at least + :data:`NOISE_FLOOR`. + + Examples + -------- + >>> noise([1.0, 1.0, 1.0], [1.01, 0.97, 1.0], minimum=3) + 0.03 + >>> noise([1.0, 1.0], [1.0, 1.0], minimum=3) is None + True + """ + measured = ratios(again, builtin) + if len(measured) < minimum: + return None + return round(max(NOISE_FLOOR, *(abs(value - 1.0) for value in measured)), 12) + + +@dataclass(frozen=True) +class Verdict: + """Whether one candidate replaces the built-in value. + + Attributes + ---------- + admitted : bool + Whether it won every round by more than the noise. + score : float or None + The median per-round ratio to the built-in; ``None`` without enough rounds. + worst : float or None + The largest per-round ratio. + rounds : int + The complete rounds compared. + reason : str + Why it was or was not admitted. + windows : Mapping[str, float] + Each problem's median per-round ratio, where the problems were timed apart. + """ + + admitted: bool + score: float | None + worst: float | None + rounds: int + reason: str + windows: Mapping[str, float] = field(default_factory=dict) + + def record(self) -> dict[str, object]: + """Return the verdict as JSON data. + + Returns + ------- + dict + The fields as JSON data. + """ + return { + "admitted": self.admitted, + "score": self.score, + "worst": self.worst, + "rounds": self.rounds, + "reason": self.reason, + "windows": dict(self.windows), + } + + +def admit( + candidate: Times, + builtin: Times, + noise: float, + minimum: int, + windows: Mapping[str, Times] | None = None, +) -> Verdict: + """Decide one candidate against the built-in value. + + Parameters + ---------- + candidate, builtin : sequence of float or None + Seconds per round, interleaved. + noise : float + The session's A/A noise (:func:`noise`). + minimum : int + The fewest complete rounds that decide. + windows : Mapping[str, sequence of float or None] or None, optional + Each problem's per-round ratio of the candidate to the built-in value. + + Returns + ------- + Verdict + Admitted only if every round's ratio is below ``1 - noise`` and no + problem's median ratio is above ``1 + noise +`` :data:`TOLERANCE`. + + Examples + -------- + >>> admit([0.9, 0.92, 0.91], [1.0, 1.0, 1.0], noise=0.02, minimum=3).admitted + True + >>> admit([0.8, 0.99, 0.8], [1.0, 1.0, 1.0], noise=0.02, minimum=3).reason + 'lost or tied in a round: worst ratio 0.990 against 0.980' + >>> admit( + ... [0.8] * 3, [1.0] * 3, 0.02, 3, {"small": [0.5] * 3, "large": [1.3] * 3} + ... ).reason + 'slower on large: median ratio 1.300 against 1.070' + """ + measured = ratios(candidate, builtin) + if len(measured) < minimum: + return Verdict( + False, None, None, len(measured), f"{len(measured)} of {minimum} rounds" + ) + score, worst = statistics.median(measured), max(measured) + medians = { + name: statistics.median(found) + for name, times in (windows or {}).items() + if (found := ratios(times, [1.0] * len(times))) + } + if worst >= 1.0 - noise: + return Verdict( + False, + score, + worst, + len(measured), + f"lost or tied in a round: worst ratio {worst:.3f} against " + f"{1.0 - noise:.3f}", + medians, + ) + slower = max(medians.items(), key=lambda item: item[1], default=None) + if slower is not None and slower[1] > 1.0 + noise + TOLERANCE: + return Verdict( + False, + score, + worst, + len(measured), + f"slower on {slower[0]}: median ratio {slower[1]:.3f} against " + f"{1.0 + noise + TOLERANCE:.3f}", + medians, + ) + return Verdict(True, score, worst, len(measured), "won every round", medians) + + +def choose( + candidates: Mapping[str, Times], + builtin: Times, + noise: float, + minimum: int, + windows: Mapping[str, Mapping[str, Times]] | None = None, +) -> tuple[str | None, dict[str, Verdict]]: + """Choose among a setting's candidates: the admitted one with the lowest score. + + Parameters + ---------- + candidates : Mapping[str, sequence of float or None] + At most :data:`MAX_CANDIDATES` candidates' seconds per round, by name. + builtin : sequence of float or None + The built-in value's, interleaved with them. + noise : float + The session's A/A noise. + minimum : int + The fewest complete rounds that decide. + windows : Mapping[str, Mapping[str, sequence]] or None, optional + Each candidate's per-problem per-round ratios (:func:`admit`). + + Returns + ------- + tuple + The chosen name, or ``None`` to keep the built-in value; and every + candidate's verdict. + + Raises + ------ + ValueError + For more than :data:`MAX_CANDIDATES` candidates. + """ + if len(candidates) > MAX_CANDIDATES: + raise ValueError( + f"{len(candidates)} candidates; a setting chooses among at most " + f"{MAX_CANDIDATES}" + ) + verdicts = { + name: admit(times, builtin, noise, minimum, (windows or {}).get(name)) + for name, times in candidates.items() + } + admitted = [ + (verdict.score, name) + for name, verdict in verdicts.items() + if verdict.admitted and verdict.score is not None + ] + return (min(admitted)[1] if admitted else None), verdicts + + +@dataclass(frozen=True) +class Validation: + """Whether a combination of admitted settings may be published. + + Attributes + ---------- + passed : bool + No window slower than ``1 + noise +`` :data:`TOLERANCE`, and a geometric + mean below one. + windows : Mapping[str, float] + Each window's median per-round ratio to the table. + geomean : float or None + Their geometric mean; ``None`` when a window has too few rounds. + reason : str + Why it passed or failed. + """ + + passed: bool + windows: Mapping[str, float] + geomean: float | None + reason: str + + def record(self) -> dict[str, object]: + """Return the validation as JSON data. + + Returns + ------- + dict + The fields as JSON data. + """ + return { + "passed": self.passed, + "windows": dict(self.windows), + "geomean": self.geomean, + "reason": self.reason, + } + + +def validate( + windows: Mapping[str, tuple[Times, Times]], noise: float, minimum: int +) -> Validation: + """Validate a combination on fresh rounds of every calibration window. + + Parameters + ---------- + windows : Mapping[str, tuple] + Each window's seconds per round under the combination and under the table. + noise : float + The session's A/A noise. + minimum : int + The fewest complete rounds a window needs. + + Returns + ------- + Validation + The verdict, with each window's median ratio. + + Examples + -------- + >>> validate({"a": ([0.9] * 3, [1.0] * 3)}, noise=0.02, minimum=3).passed + True + >>> validate( + ... {"a": ([0.8] * 3, [1.0] * 3), "b": ([1.08] * 3, [1.0] * 3)}, + ... noise=0.02, + ... minimum=3, + ... ).reason + 'b is slower: 1.080 against 1.070' + """ + medians: dict[str, float] = {} + for name, (combined, table) in windows.items(): + measured = ratios(combined, table) + if len(measured) < minimum: + return Validation( + False, medians, None, f"{name}: {len(measured)} of {minimum} rounds" + ) + medians[name] = statistics.median(measured) + if not medians: + return Validation(False, medians, None, "no windows") + geomean = math.exp(statistics.fmean(math.log(value) for value in medians.values())) + for name, value in medians.items(): + if value > 1.0 + noise + TOLERANCE: + return Validation( + False, + medians, + geomean, + f"{name} is slower: {value:.3f} against {1.0 + noise + TOLERANCE:.3f}", + ) + if geomean >= 1.0: + return Validation(False, medians, geomean, f"geometric mean {geomean:.3f}") + return Validation(True, medians, geomean, f"geometric mean {geomean:.3f}") + + +def fallbacks(groups: Sequence[str]) -> list[tuple[str, ...]]: + """Return the combinations to validate in turn, dropping groups in order. + + Parameters + ---------- + groups : sequence of str + The groups with an admitted setting, among :data:`DROP_ORDER`. + + Returns + ------- + list of tuple of str + Every admitted group, then that set less grain, less grain and FFT, and so + on; empty combinations are left out. + + Raises + ------ + ValueError + For a group outside :data:`DROP_ORDER`. + + Examples + -------- + >>> fallbacks(["process", "grain", "fft"]) + [('grain', 'fft', 'process'), ('fft', 'process'), ('process',)] + >>> fallbacks(["process"]) + [('process',)] + """ + unknown = set(groups) - set(DROP_ORDER) + if unknown: + raise ValueError(f"unknown groups {sorted(unknown)}") + ordered = [group for group in DROP_ORDER if group in groups] + return [tuple(ordered[index:]) for index in range(len(ordered))] diff --git a/src/solprop/cli.py b/src/solprop/cli.py index 6d9eeb3b1..89516d83e 100644 --- a/src/solprop/cli.py +++ b/src/solprop/cli.py @@ -7,8 +7,10 @@ ``solprop run recipe.toml -o out.h5`` builds and runs a recipe, ``solprop resume out.h5`` continues it from the file alone, ``solprop check`` reports what stops it building, ``solprop export --python`` prints the equivalent -script, and ``solprop scan`` is ``python -m solprop.scan``. The same commands run as -``python -m solprop``. The manual page is ``docs/guide/setup/recipes.md``. +script, ``solprop scan`` is ``python -m solprop.scan``, and ``solprop calibrate`` +measures, shows or forgets the native backend's machine profile (SEP 46). The same +commands run as ``python -m solprop``. The manual page is +``docs/guide/setup/recipes.md``. """ import argparse @@ -84,6 +86,15 @@ def _parser() -> argparse.ArgumentParser: commands.add_parser( "scan", help="run a scan script (python -m solprop.scan)", add_help=False ) + calibrate = commands.add_parser( + "calibrate", + help="calibrate the native backend to this machine (SEP 46)", + description="Measure this machine's native execution settings, or show, " + "export, import or forget its profile.", + ) + from solprop.calibration.command import add_arguments # noqa: PLC0415 - lazy + + add_arguments(calibrate) return parser @@ -299,17 +310,37 @@ def main(argv: Sequence[str] | None = None) -> int: return scan(arguments[1:]) args = _parser().parse_args(arguments) try: - if args.command == "run": - return _run(args.recipe, args.output, args.progress, resume=args.resume) - if args.command == "check": - return _check(args.recipe) - if args.command == "resume": - mode = "interpolated" if args.interpolated else True - return _resume(args.output, mode, args.progress) - from solprop.recipe import Recipe # noqa: PLC0415 - imports the physics - - sys.stdout.write(Recipe.load(args.recipe).to_python(args.output)) + return _command(args) except (SolpropError, OSError) as error: sys.stderr.write(f"solprop: {error}\n") return 2 + + +def _command(args: argparse.Namespace) -> int: + """Carry out one parsed command other than ``scan``. + + Parameters + ---------- + args : argparse.Namespace + The parsed command line. + + Returns + ------- + int + The command's exit status. + """ + if args.command == "run": + return _run(args.recipe, args.output, args.progress, resume=args.resume) + if args.command == "check": + return _check(args.recipe) + if args.command == "resume": + mode = "interpolated" if args.interpolated else True + return _resume(args.output, mode, args.progress) + if args.command == "calibrate": + from solprop.calibration.command import run # noqa: PLC0415 - reads the store + + return run(args) + from solprop.recipe import Recipe # noqa: PLC0415 - imports the physics + + sys.stdout.write(Recipe.load(args.recipe).to_python(args.output)) return 0 diff --git a/src/solprop/driver/executors/pool.py b/src/solprop/driver/executors/pool.py index 4d1c3b007..8fab4922b 100644 --- a/src/solprop/driver/executors/pool.py +++ b/src/solprop/driver/executors/pool.py @@ -685,8 +685,11 @@ def _spawn( process = context.Process( target=_serve, args=(child, work, cpus, threads if after_load else None) ) + # Workers sleep between regions unless the user chose, or a calibration's row for + # the workers' allocation did (solprop SEP 46); they never calibrate themselves. environment = dict.fromkeys(_THREAD_VARIABLES, str(threads)) | { - "OMP_WAIT_POLICY": "passive" + "SOLPROP_NATIVE_WAIT_REQUEST": "passive", + "SOLPROP_CALIBRATION_ROLE": "worker", } removed = LOAD_TIME_BLAS_VARIABLES if after_load else () with spawn_environment(environment, removed): @@ -899,6 +902,12 @@ def _admit(self, work: Callable, first: object) -> tuple[tuple[int, ...], int]: Allocated CPUs and admitted worker count. """ available = _cpus() + # SEP 46: the workers' allocation is calibrated at the standard level before + # they start, in children pinned like one worker, unless a profile row of + # that level already matches. + from solprop.calibration.first_use import for_workers # noqa: PLC0415 - lazy + + for_workers(available[: self.threads]) process, connection = _spawn( work, self.threads, available[: self.threads] if self.pin else () ) diff --git a/src/solprop/driver/executors/slurm.py b/src/solprop/driver/executors/slurm.py index cc721104e..10d45ffc0 100644 --- a/src/solprop/driver/executors/slurm.py +++ b/src/solprop/driver/executors/slurm.py @@ -254,7 +254,7 @@ def render(self) -> str: for variable in _THREAD_VARIABLES: if variable != "SOLPROP_THREADS": lines.append(f" export {variable}={self.threads}") - lines.append(" export OMP_WAIT_POLICY=passive") + lines.append(" export SOLPROP_NATIVE_WAIT_REQUEST=passive") command = [ sys.executable, "-m", diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 504710235..d404942fa 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -532,6 +532,7 @@ def __reduce__(self) -> tuple[object, tuple[str, str]]: if TYPE_CHECKING: + from solprop.calibration.refine import RefinementReport from solprop.driver.estimate import RunEstimate @@ -3770,6 +3771,31 @@ def estimate(self) -> "RunEstimate": # noqa: UP037 - checked at run time return estimate(self) + def calibrate(self, budget: float = 60.0) -> "RefinementReport": # noqa: UP037 - later + """Refine the native backend's settings for this simulation (SEP 46 stage 2). + + Fresh native workspaces of this simulation's translated problem are built in + this process under candidate settings and timed by trial steps along its + length; the settings that win, stay accurate and validate together are stored + under its execution descriptor, and every later run or scan point of the same + execution applies them. The simulation itself is not changed. Running + ``solprop calibrate`` first gives the machine profile this refines + (:mod:`solprop.calibration`). + + Parameters + ---------- + budget : float, optional + The deadline, in seconds. + + Returns + ------- + solprop.calibration.refine.RefinementReport + What was kept and whether it was stored. + """ + from solprop.calibration.refine import refine # noqa: PLC0415 - imports native + + return refine(self, budget) + def output_bytes(self) -> int: """Return the size of the run's output, without constructing a backend. diff --git a/src/solprop/exceptions.py b/src/solprop/exceptions.py index 4e959ea7f..2352522d7 100644 --- a/src/solprop/exceptions.py +++ b/src/solprop/exceptions.py @@ -16,6 +16,8 @@ "BackendError", "BoundaryError", "BoundaryWarning", + "CalibrationError", + "CalibrationNotice", "ConvergenceError", "CouplingError", "DiluteScalingWarning", @@ -239,6 +241,10 @@ class BackendError(SolpropError, RuntimeError): """A backend is unavailable, misused or failed during propagation.""" +class CalibrationError(BackendError): + """A machine calibration could not run, read or write its profile (SEP 46).""" + + class OutputError(SolpropError, RuntimeError): """An output handler could not save, load or resume a run.""" @@ -383,6 +389,16 @@ class GrazingIncidenceWarning(SolpropWarning): """ +class CalibrationNotice(SolpropWarning): + """The native backend is calibrating itself to this machine (SEP 46 section 6.10). + + Issued once, before a first native run with no profile for its machine, build + and allocation runs the quick calibration (about 20 s), saying where the result + goes and how to choose another level or turn it off. Silence it with the standard + warnings filters, or with ``SOLPROP_CALIBRATION=use``. + """ + + class HostMarchWarning(SolpropWarning): """The native backend steps a run with solprop's Python march. diff --git a/tests/calibration/__init__.py b/tests/calibration/__init__.py new file mode 100644 index 000000000..f02439f9b --- /dev/null +++ b/tests/calibration/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Qualify the machine calibration (SEP 46).""" diff --git a/tests/calibration/test_child.py b/tests/calibration/test_child.py new file mode 100644 index 000000000..d1b862698 --- /dev/null +++ b/tests/calibration/test_child.py @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A calibration child's protocol, and one real calibration (SEP 46 §6.2).""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import cast + +import pytest + +from solprop.calibration import child, problems +from solprop.calibration.controller import calibrate + + +def _two_cpus() -> list[int] | None: + """Return two CPUs of this process's mask, or ``None`` without masks.""" + if not hasattr(os, "sched_getaffinity"): + return None + return sorted(os.sched_getaffinity(0))[:2] + + +def _serve(*requests: dict[str, object]) -> list[dict[str, object]]: + replies = io.StringIO() + lines = "\n".join(json.dumps(request) for request in requests) + "\n\n" + child.serve(io.StringIO(lines), replies) + return [json.loads(line) for line in replies.getvalue().splitlines()] + + +def test_a_child_prepares_times_and_probes_regions() -> None: + record: dict[str, object] = {"op": "time", "problem": "gnlse", "repeats": 2} + replies = _serve( + {"op": "prepare", "problems": {"gnlse": 0.5}}, + record | {"record": None}, + {"op": "regions", "elements": 100, "repeats": 5}, + {"op": "sizing", "record": None}, + {"op": "colour"}, + {"op": "time", "problem": "missing", "repeats": 1}, + {"op": "exit"}, + {"op": "prepare", "problems": {}}, + ) + prepared = replies[0]["ok"] + assert isinstance(prepared, dict) + assert prepared["problems"]["gnlse"]["shape"][-1] > 0 # pyright: ignore[reportIndexIssue, reportUnknownMemberType] + assert set(prepared) >= {"process", "execution", "builtin", "cpus"} + timed = replies[1]["ok"] + assert isinstance(timed, float) + assert timed > 0 + regions = replies[2]["ok"] + assert isinstance(regions, dict) + assert set(regions) == {"copy", "empty"} + sizing = replies[3]["ok"] + assert isinstance(sizing, dict) + assert any(key.startswith("kernels.stage.") for key in sizing["gnlse"]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + assert replies[4] == {"error": "ValueError: unknown request 'colour'"} + assert "KeyError" in str(replies[5]["error"]) + # Nothing is answered after exit. + assert replies[6] == {"ok": True} + assert len(replies) == 7 + + +def test_a_child_times_under_a_given_record() -> None: + from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports] + BUILTIN_EXECUTION, + ) + + serial = BUILTIN_EXECUTION.updated( + {"stage_threshold": 1 << 40, "stage_full": 1 << 40} + ) + replies = _serve( + {"op": "prepare", "problems": {"gnlse": 0.5}}, + {"op": "time", "problem": "gnlse", "repeats": 1, "record": serial.native()}, + ) + assert isinstance(replies[1]["ok"], float) + + +def test_every_calibration_problem_builds() -> None: + for name in problems.PROBLEMS: + simulation = problems.build(name) + assert simulation.settings.backend == "native" + with pytest.raises(KeyError): + problems.build("unknown") + + +def test_pinning_sets_the_affinity_mask() -> None: + if sys.platform == "win32": # pragma: no cover - Windows + import ctypes # noqa: PLC0415 - Windows only + + kernel = ctypes.WinDLL("kernel32") + kernel.GetCurrentProcess.restype = ctypes.c_void_p + kernel.GetProcessAffinityMask.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.POINTER(ctypes.c_size_t), + ) + kernel.SetProcessAffinityMask.argtypes = (ctypes.c_void_p, ctypes.c_size_t) + process = kernel.GetCurrentProcess() + + def mask() -> int: + own, system = ctypes.c_size_t(), ctypes.c_size_t() + assert kernel.GetProcessAffinityMask( + process, ctypes.byref(own), ctypes.byref(system) + ) + return own.value + + held = mask() + try: + first = (held & -held).bit_length() - 1 + child.pin([first]) + assert mask() == 1 << first + child.pin([]) + assert mask() == 1 << first + finally: + kernel.SetProcessAffinityMask(process, held) + return + if not hasattr(os, "sched_getaffinity"): + # macOS has no affinity masks: pinning leaves the process as it is. + child.pin([0]) + return + before = os.sched_getaffinity(0) + try: + first = min(before) + child.pin([first]) + assert os.sched_getaffinity(0) == {first} + child.pin([]) + assert os.sched_getaffinity(0) == {first} + finally: + os.sched_setaffinity(0, before) + + +def test_the_child_module_serves_on_its_standard_streams(tmp_path: Path) -> None: + cpus = ( + sorted(os.sched_getaffinity(0))[:2] if hasattr(os, "sched_getaffinity") else [] + ) + completed = subprocess.run( + [sys.executable, "-m", "solprop.calibration.child", ",".join(map(str, cpus))], + input=json.dumps({"op": "exit"}) + "\n", + capture_output=True, + text=True, + check=True, + env=os.environ | {"SOLPROP_CALIBRATION": "off"}, + ) + assert json.loads(completed.stdout) == {"ok": True} + child_main = child.main # the in-process entry point takes the same argument + assert callable(child_main) + + +@pytest.mark.medium +def test_a_quick_calibration_runs_its_children_and_publishes_or_declines( + tmp_path: Path, +) -> None: + """A real quick calibration of two CPUs: published, or inconclusive on a busy + runner; either way within its deadline plus the children's shutdown.""" + cpus = ( + sorted(os.sched_getaffinity(0))[:2] + if hasattr(os, "sched_getaffinity") + else None + ) + environ = os.environ | {"SOLPROP_CACHE_DIR": str(tmp_path)} + report = calibrate("quick", 60.0, environ=environ, cpus=cpus) + assert report.status in {"published", "inconclusive"} + assert report.seconds < 60.0 + 10.0 + if report.status == "published": + assert report.path is not None and report.path.exists() + assert {"threads", "wait"} <= set(report.chosen) + + +def test_the_childs_entry_point_keeps_its_protocol_apart( + monkeypatch: pytest.MonkeyPatch, +) -> None: + replies = io.StringIO() + pinned: list[list[int]] = [] + monkeypatch.setattr(child, "pin", pinned.append) + monkeypatch.setattr(child.os, "dup", lambda descriptor: 99) + monkeypatch.setattr(child.os, "dup2", lambda source, target: None) + monkeypatch.setattr(child.os, "fdopen", lambda *args, **kwargs: replies) + monkeypatch.setattr(child.sys, "stdin", io.StringIO('{"op": "exit"}\n')) + monkeypatch.setattr(child.sys, "stdout", child.sys.stdout) + monkeypatch.setattr(child.warnings, "simplefilter", lambda action: None) + assert child.main(["3,4"]) == 0 + assert pinned == [[3, 4]] + assert json.loads(replies.getvalue()) == {"ok": True} + monkeypatch.setattr(child.sys, "argv", ["child"]) + monkeypatch.setattr(child.sys, "stdin", io.StringIO("")) + assert child.main() == 0 + assert pinned[-1] == [] + + +def test_a_child_captures_each_plans_wisdom_and_times_under_a_planning() -> None: + replies = _serve( + {"op": "prepare", "problems": {"gnlse": 1.0}}, + {"op": "capture", "effort": "measure", "limit": 0.5, "record": None}, + ) + captured = replies[1]["ok"] + assert isinstance(captured, dict) + wisdom = cast(dict[str, str], captured["wisdom"]) + groups = cast(dict[str, str], captured["groups"]) + assert wisdom + assert set(wisdom) <= set(groups) + assert all(text.startswith("(fftw-3") for text in wisdom.values()) + assert set(cast(dict[str, list[str]], captured["problems"])) == {"gnlse"} + assert captured["skipped"] == [] + # A budget too small for any problem's plans skips them all, and says so. + short = _serve( + {"op": "prepare", "problems": {"gnlse": 1.0, "line": 1.0}}, + {"op": "capture", "effort": "measure", "limit": 0.5, "budget": 0.0}, + )[1]["ok"] + assert isinstance(short, dict) + assert short["skipped"] == ["gnlse", "line"] + assert short["wisdom"] == {} + # A budget with room for the problem's plans keeps the per-plan limit. + roomy = _serve( + {"op": "prepare", "problems": {"gnlse": 1.0}}, + {"op": "capture", "effort": "measure", "limit": 0.5, "budget": 60.0}, + )[1]["ok"] + assert isinstance(roomy, dict) + assert roomy["skipped"] == [] + assert roomy["wisdom"] + planning = { + "mode": "profile", + "efforts": {"rows": "measure", "threaded_rows": "measure", "planes": "measure"}, + "wisdom": wisdom, + } + timed = _serve( + {"op": "prepare", "problems": {"gnlse": 1.0}}, + { + "op": "time", + "problem": "gnlse", + "repeats": 1, + "record": None, + "planning": planning, + }, + ) + assert isinstance(timed[1]["ok"], float) diff --git a/tests/calibration/test_controller.py b/tests/calibration/test_controller.py new file mode 100644 index 000000000..88f99e9ba --- /dev/null +++ b/tests/calibration/test_controller.py @@ -0,0 +1,1043 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The calibration controller, on scripted children (SEP 46 §7 items 4, 5, 11).""" + +from __future__ import annotations + +import dataclasses +import json +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any, ClassVar, cast + +import pytest + +from solprop.calibration import _native, controller +from solprop.calibration.controller import ( + LEVELS, + CalibrationReport, + Candidate, + builtin_wait, + calibrate, + candidates, + nested, +) +from solprop.exceptions import CalibrationError + +type Factor = Callable[[str, int], float] + + +class _Scripted(controller._Child): # pyright: ignore[reportPrivateUsage] + """A child whose window times follow a script: seconds = 1 ms x factor.""" + + factor: ClassVar[Factor] = staticmethod(lambda name, call: 1.0) + # A record's factor: the grain candidates' windows, by the record they run. + by_record: ClassVar[Callable[[Mapping[str, object] | None, int], float]] = ( + staticmethod(lambda record, call: 1.0) + ) + by_planning: ClassVar[Callable[[Mapping[str, object] | None, int], float]] = ( + staticmethod(lambda planning, call: 1.0) + ) + # A problem's factor, by the candidate and the record it runs. + by_problem: ClassVar[Callable[[str, str, Mapping[str, object] | None], float]] = ( + staticmethod(lambda name, problem, record: 1.0) + ) + shape: ClassVar[list[int]] = [1, 1, 1, 4096] + # The transforms a capture finds, by plan key, with their groups. + plans: ClassVar[dict[str, str]] = { + "r2c:4096:1:4096:2049:out:0:0:t1": "rows", + "c2c-forward:4096:4:4096:4096:out:0:0:t8": "threaded_rows", + } + sizing: ClassVar[dict[str, object]] = { + "kernels.stage.4096": {"threads": 8}, + "kernels.response.98304.samples.4096": {"threads": 4}, + } + fail: ClassVar[frozenset[str]] = frozenset() + fail_prepare: ClassVar[frozenset[str]] = frozenset() + started: ClassVar[list[str]] = [] + + def __init__( + self, candidate: Candidate, cpus: Sequence[int], environ: Mapping[str, str] + ) -> None: + self.candidate = candidate + self.cpus = tuple(cpus) + self.environ = dict(environ) + self.report: dict[str, object] = {} + self.calls = 0 + self.pending: Mapping[str, object] = {} + self.prepared: list[str] = [] + self.stopped = False + self.readers = () + _Scripted.started.append(candidate.name) + + def send(self, request: Mapping[str, object]) -> None: + self.pending = request + + def receive(self, timeout: float) -> object: + request = self.pending + failing = self.fail_prepare if request["op"] == "prepare" else self.fail + if self.candidate.name in failing: + raise controller._ChildError(f"{self.candidate.name} exited") # pyright: ignore[reportPrivateUsage] + if request["op"] == "prepare": + problems = request["problems"] + assert isinstance(problems, dict) + self.prepared = list(cast(dict[str, float], problems)) + return { + "problems": { + name: {"step": 1e-3, "shape": list(type(self).shape)} + for name in problems + }, + "process": { + "threads": self.candidate.threads or 8, + "wait": self.candidate.wait or "spin", + }, + "execution": _native.execution.builtin("Linux").native(), + } + if request["op"] == "sizing": + return {name: dict(type(self).sizing) for name in self.prepared} + if request["op"] == "capture": + effort = request["effort"] + return { + "wisdom": {key: f"({effort} {key})" for key in type(self).plans}, + "groups": dict(type(self).plans), + "problems": {name: list(type(self).plans) for name in self.prepared}, + "seconds": 0.1, + } + if request["op"] == "regions": + return {"copy": 1e-6, "empty": 1e-6} + assert request["op"] == "time" + self.calls += 1 + record = cast(Mapping[str, object] | None, request.get("record")) + planning = cast(Mapping[str, object] | None, request.get("planning")) + return ( + 1e-3 + * type(self).by_problem( + self.candidate.name, cast(str, request["problem"]), record + ) + * type(self).factor(self.candidate.name, self.calls) + * type(self).by_record(record, self.calls) + * type(self).by_planning(planning, self.calls) + ) + + def request(self, request: Mapping[str, object], timeout: float) -> object: + self.send(request) + return self.receive(timeout) + + def stop(self) -> None: + self.stopped = True + + +@pytest.fixture +def scripted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> tuple[type[_Scripted], dict[str, str]]: + """Scripted children on a sixteen-CPU machine with an empty store.""" + + class Script(_Scripted): + started: ClassVar[list[str]] = [] + + monkeypatch.setattr(controller, "_Child", Script) + monkeypatch.setattr(controller, "_telemetry", lambda path=None: None) + monkeypatch.setattr(controller, "_native_budget", lambda cpus: 8) + monkeypatch.setattr(controller._process, "top_tier_cores", lambda: None) # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr(controller, "nested", lambda topology, cpus: []) + # Sixteen CPUs whatever the runner has, so eight and four of them are classes apart. + machine = _native.topology._flat(tuple(range(16)), "test") # pyright: ignore[reportPrivateUsage] + # The profile module binds its own name at import: patch both, so that one + # imported here first is not left reading the fake machine after the test. + for module in (_native.topology, _native.profile): + monkeypatch.setattr(module, "read_topology", lambda *a, **k: machine) + environ = {"SOLPROP_CACHE_DIR": str(tmp_path / "store"), "PATH": ""} + return Script, environ + + +def _rows(environ: Mapping[str, str]) -> list[dict[str, object]]: + """Return the current generation's rows, as a reader finds them.""" + store = Path(environ["SOLPROP_CACHE_DIR"]) / "machines" + for current in store.glob("*/current"): + name = current.read_text(encoding="utf-8").strip() + generation = current.parent / f"{name}.json" + return json.loads(generation.read_text(encoding="utf-8"))["rows"] + return [] + + +def test_the_built_in_wait_spins_unless_the_team_is_wider_than_the_top_tier() -> None: + assert builtin_wait(6, 6) == "spin" + assert builtin_wait(18, 6) == "passive" + assert builtin_wait(36, None) == "spin" + + +def _allocation( + cores: int, *, kinds: tuple[int, ...] | None = None, sockets: int = 1, l3: int = 1 +) -> object: + return _native.allocation.AllocationClass( + cores=cores, + kinds=kinds or (cores,), + l2_groups=0, + l3_groups=l3, + numa_nodes=sockets, + sockets=sockets, + smt=False, + ) + + +def test_candidates_try_the_other_wait_and_narrower_teams() -> None: + standard, quick = LEVELS["standard"], LEVELS["quick"] + # A two-socket server: the other wait, one socket, half of it, passive on a socket. + server = candidates(standard, _allocation(36, sockets=2), 36) # pyright: ignore[reportArgumentType] + assert [c.name for c in server] == ["passive/36", "spin/18", "passive/18"] + # Its L3 groups: an EPYC with two eight-core groups per socket. + epyc = candidates(standard, _allocation(32, sockets=2, l3=4), 32) # pyright: ignore[reportArgumentType] + assert [c.name for c in epyc] == ["passive/32", "spin/16", "spin/8", "passive/16"] + # Two kinds of core: the fastest kind's, at the rule's wait for each width. + mac = candidates(standard, _allocation(18, kinds=(6, 12)), 18, top_tier=6) # pyright: ignore[reportArgumentType] + # 9 is wider than the top tier, so the rule's wait there is passive. + assert [c.name for c in mac] == ["spin/18", "passive/9", "spin/6", "spin/9"] + # Quick has no production-sized problem to protect, so it tries no narrower team. + assert [c.name for c in candidates(quick, _allocation(36, sockets=2), 36)] == [ # pyright: ignore[reportArgumentType] + "passive/36" + ] + # One core: only the other wait. + assert [c.name for c in candidates(standard, _allocation(1), 1)] == ["passive/1"] # pyright: ignore[reportArgumentType] + + +def test_a_childs_environment_drops_the_users_settings() -> None: + base = { + "SOLPROP_THREADS": "4", + "OMP_WAIT_POLICY": "active", + "SOLPROP_FFTW_WISDOM": "/w", + "HOME": "/h", + } + built = Candidate().environment(base) + assert built == { + "HOME": "/h", + "SOLPROP_CALIBRATION": "off", + "SOLPROP_FFTW_PLAN": "estimate", + "PYTHONUNBUFFERED": "1", + "OPENBLAS_THREAD_TIMEOUT": "20", + } + spin = Candidate(threads=6, wait="spin") + assert spin.name == "spin/6" + assert spin.environment(base)["SOLPROP_NATIVE_THREADS"] == "6" + assert spin.environment(base)["SOLPROP_NATIVE_WAIT_REQUEST"] == "spin" + assert Candidate(threads=6).name == "default/6" + + +def test_a_childs_blas_threads_go_idle_unless_the_user_set_it() -> None: + candidate = Candidate(threads=4, wait="spin") + assert candidate.environment({})["OPENBLAS_THREAD_TIMEOUT"] == "20" + kept = candidate.environment({"OPENBLAS_THREAD_TIMEOUT": "28"}) + assert kept["OPENBLAS_THREAD_TIMEOUT"] == "28" + + +def test_nested_allocations_are_one_socket_node_and_cache_group() -> None: + topology = _native.topology.Topology( + cpus=tuple(range(8)), + core={cpu: cpu for cpu in range(8)}, + socket={cpu: cpu // 4 for cpu in range(8)}, + numa={cpu: cpu // 4 for cpu in range(8)}, + l2={cpu: cpu for cpu in range(8)}, + l3={cpu: cpu // 2 for cpu in range(8)}, + ) + assert nested(topology, range(8)) == [(0, 1, 2, 3), (0, 1)] + assert nested(topology, [4, 5, 6, 7]) == [(4, 5)] + assert nested(topology, [0, 1]) == [] + + +def test_telemetry_reads_running_tasks_and_steal(tmp_path: Path) -> None: + stat = tmp_path / "stat" + stat.write_text( + "cpu 100 0 100 700 0 0 0 100 0 0\ncpu0 1 2 3 4 5 6 7 8\nprocs_running 3\n", + encoding="utf-8", + ) + values = controller._telemetry(stat) # pyright: ignore[reportPrivateUsage] + assert values == { + "total": 1000.0, + "steal": 100.0, + "running": 3.0, + "cpu0.total": 36.0, + "cpu0.busy": 27.0, + } + assert controller._telemetry(tmp_path / "missing") is None # pyright: ignore[reportPrivateUsage] + busy = controller._busy # pyright: ignore[reportPrivateUsage] + assert busy(None, values, 4) == "" + quiet = {"total": 0.0, "steal": 0.0, "running": 2.0} + assert busy(quiet, {"total": 1000.0, "steal": 50.0, "running": 2.0}, 0) == "" + assert "stolen" in busy(quiet, {"total": 1000.0, "steal": 200.0, "running": 2.0}, 0) + loaded = {"total": 0.0, "steal": 0.0, "running": 9.0} + assert busy(loaded, loaded, 4) == "7 other tasks running for 4 idle CPUs" + # A sibling of the allocation's cores more than half busy shares a timed core. + sibling = quiet | {"cpu5.total": 100.0, "cpu5.busy": 10.0} + shared = {"total": 0.0, "steal": 0.0, "running": 2.0} + shared |= {"cpu5.total": 200.0, "cpu5.busy": 90.0} + assert busy(sibling, shared, 4, [5]) == ( + "CPU 5, an SMT sibling of the allocation's cores, was 80 % busy with other work" + ) + assert busy(sibling, sibling, 4, [5, 6]) == "" + + +def test_a_candidate_that_wins_every_round_and_its_validation_is_published( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.factor = staticmethod(lambda name, call: 0.8 if name == "spin/4" else 1.0) + lines: list[str] = [] + report = calibrate( + "standard", environ=environ, cpus=range(8), progress=lines.append + ) + assert report.status == "published" + assert report.chosen == {"threads": 4, "wait": "spin"} + assert report.builtin == {"threads": 8, "wait": "spin"} + assert report.reason.startswith("spin/4 won every round and its validation") + assert report.path is not None + assert any("validating spin/4" in line for line in lines) + evidence = report.evidence + assert evidence["noise"] == 0.02 + validation = evidence["validation"] + assert isinstance(validation, dict) + assert validation["passed"] is True + rows = _rows(environ) + assert len(rows) == 1 + assert rows[0]["process"] == {"threads": 4, "wait": "spin"} + assert rows[0]["fitted_under"] == {"threads": 4, "wait": "spin"} + text = report.describe() + assert "threads: 4 (built-in 8)" in text + assert "wait: spin\n" in text + + +def test_no_winner_publishes_the_built_in_rules( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + # spin/4 wins on average but loses one round. + script.factor = staticmethod( + lambda name, call: ( + (1.2 if (call - 1) // 3 == 1 else 0.7) if name == "spin/4" else 1.0 + ) + ) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "published" + assert report.chosen == report.builtin + assert report.reason.startswith("the built-in rules kept") + assert "validation" not in report.evidence + + +def test_a_winner_that_fails_validation_is_dropped( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + # Faster in every window of the rounds, but its last window slower in validation. + calls: dict[str, int] = {} + + def factor(name: str, call: int) -> float: + if name != "passive/8": + return 1.0 + calls[name] = call + # Each round times three windows; validation follows three rounds of them. + in_validation = call > 9 + window = (call - 1) % 3 + if in_validation and window == 2: + return 1.5 + return 0.6 + + script.factor = staticmethod(factor) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "published" + assert report.chosen == report.builtin + validation = report.evidence["validation"] + assert isinstance(validation, dict) + assert validation["passed"] is False + + +def test_noise_above_the_limit_publishes_nothing( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + # The built-in child varies by 30 % between its two slots of a round. + script.factor = staticmethod( + lambda name, call: ( + (1.3 if (call - 1) // 3 % 2 else 1.0) if name == "built-in" else 1.0 + ) + ) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "inconclusive" + assert report.reason.startswith("A/A noise") + assert _rows(environ) == [] + found = _native.store.Store(Path(environ["SOLPROP_CACHE_DIR"])) + assert found.inconclusive_since(report.fingerprint, _key(report)) is not None + + +def _key(report: CalibrationReport) -> str: + return _native.allocation.AllocationClass.read(report.allocation).key() + + +def test_a_busy_host_or_a_short_budget_is_inconclusive( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + _, environ = scripted + loaded = {"total": 0.0, "steal": 0.0, "running": 500.0} + monkeypatch.setattr(controller, "_telemetry", lambda path=None: loaded) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "inconclusive" + assert "other tasks running" in report.reason + monkeypatch.setattr(controller, "_telemetry", lambda path=None: None) + # The whole budget is the reserve: no round fits, however fast the scripted + # children start. + whole = dataclasses.replace(controller.LEVELS["quick"], reserve=1.0) + monkeypatch.setitem(controller.LEVELS, "quick", whole) + short = calibrate("quick", environ=environ, cpus=range(8)) + assert short.status == "inconclusive" + assert short.reason == "too few rounds within the budget" + + +def test_one_cpu_is_never_measured( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + report = calibrate("standard", environ=environ, cpus=[0]) + assert report.status == "inconclusive" + assert report.reason == controller.SINGLE_CPU + assert script.started == [] + # Nothing is recorded, so nothing waits a day to try again. + found = _native.store.Store(Path(environ["SOLPROP_CACHE_DIR"])) + assert found.inconclusive_since(report.fingerprint, _key(report)) is None + + +def test_a_failed_child_publishes_nothing_and_every_child_is_reaped( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.fail_prepare = frozenset({"built-in"}) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "inconclusive" + assert "built-in exited" in report.reason + assert _rows(environ) == [] + # A built-in child that fails every window leaves no noise to judge by. + script.fail_prepare = frozenset() + script.fail = frozenset({"built-in"}) + failed = calibrate("quick", environ=environ, cpus=range(8)) + assert failed.status == "inconclusive" + assert failed.reason == "too few rounds within the budget" + + +def test_a_failed_round_of_one_candidate_is_dropped( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.fail = frozenset({"passive/8"}) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "published" + verdicts = report.evidence["verdicts"] + assert isinstance(verdicts, dict) + assert verdicts["passive/8"]["reason"] == "0 of 3 rounds" # pyright: ignore[reportUnknownMemberType] + + +def test_a_held_claim_skips_the_calibration( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + machine = _native.topology.read_topology() + allocation = _native.allocation.classify( + machine, + tuple(range(8)), + controller._process.cgroup_cpus(), # pyright: ignore[reportPrivateUsage] + ) + from solprop import calibration # noqa: PLC0415 + + found = calibration.choice(environ | {"SOLPROP_CALIBRATION": "use"}) + assert found.fingerprint is not None + store = _native.store.Store(Path(environ["SOLPROP_CACHE_DIR"])) + claim = store.claim(found.fingerprint.digest, allocation.key(), 1e12) + assert claim is not None + report = calibrate("quick", environ=environ, cpus=range(8)) + assert report.status == "held" + assert script.started == [] + claim.release() + + +def test_nested_allocations_get_rows_while_the_budget_allows( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + _, environ = scripted + monkeypatch.setattr(controller, "nested", lambda topology, cpus: [(0, 1, 2, 3)]) + report = calibrate("standard", environ=environ, cpus=range(8)) + inner = report.evidence["nested"] + assert isinstance(inner, list) + assert [item["status"] for item in inner] == ["published"] # pyright: ignore[reportUnknownVariableType] + assert len(_rows(environ)) == 2 + assert "nested" in report.describe() + # A budget spent on the allocation itself skips the nested one. + ticks = iter(range(10_000)) + monkeypatch.setattr(controller.time, "monotonic", lambda: next(ticks) * 5.0) + skipped = calibrate("standard", 300.0, environ=environ, cpus=range(8)) + inner = skipped.evidence["nested"] + assert isinstance(inner, list) + assert [item["status"] for item in inner] == ["skipped"] # pyright: ignore[reportUnknownVariableType] + + +def test_a_higher_level_row_survives_a_lower_level_calibration( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.factor = staticmethod(lambda name, call: 0.8 if name == "spin/4" else 1.0) + calibrate("standard", environ=environ, cpus=range(8)) + script.factor = staticmethod(lambda name, call: 1.0) + calibrate("quick", environ=environ, cpus=range(8)) + rows = _rows(environ) + assert [row["level"] for row in rows] == ["standard"] + assert rows[0]["process"] == {"threads": 4, "wait": "spin"} + # Another allocation's row is kept beside it. + calibrate("quick", environ=environ, cpus=range(4)) + assert sorted(str(row["level"]) for row in _rows(environ)) == ["quick", "standard"] + + +def test_levels_and_budgets_are_checked() -> None: + with pytest.raises(CalibrationError, match="unknown calibration level"): + calibrate("forever") + with pytest.raises(CalibrationError, match="must be positive"): + calibrate("quick", 0.0) + + +def test_the_built_in_width_is_the_smallest_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + budget = controller._native_budget # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr( + controller._process, "physical_cores", lambda cpus: len(cpus) // 2 + ) # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr(controller._process, "top_tier_cores", lambda: None) # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr(controller._process, "cgroup_cpus", lambda: None) # pyright: ignore[reportPrivateUsage] + assert budget(range(8)) == 4 + monkeypatch.setattr(controller._process, "cgroup_cpus", lambda: 3) # pyright: ignore[reportPrivateUsage] + assert budget(range(8)) == 3 + assert budget(()) >= 1 + + +def test_a_real_childs_errors_are_reported_and_it_is_reaped() -> None: + real = controller._Child # pyright: ignore[reportPrivateUsage] + error = controller._ChildError # pyright: ignore[reportPrivateUsage] + environ = dict(controller.os.environ) + child = real(Candidate(), (), environ) + try: + with pytest.raises(error, match="unknown request"): + child.request({"op": "colour"}, 60.0) + # Nothing asked, nothing answered within the time allowed. + with pytest.raises(error, match="overran the deadline"): + child.receive(0.0) + finally: + child.stop() + assert child.process.returncode == 0 + # The exit request's own reply, then the end of the child's output. + assert child.receive(1.0) is True + with pytest.raises(error, match="exited"): + child.receive(1.0) + with pytest.raises(error): + child.send({"op": "exit"}) + + +def test_a_child_that_ignores_its_exit_request_is_terminated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real = controller._Child # pyright: ignore[reportPrivateUsage] + child = real(Candidate(), (), dict(controller.os.environ)) + monkeypatch.setattr(child, "send", lambda request: None) + child.stop() + assert child.process.returncode is not None + assert child.process.returncode != 0 + + +def test_a_child_failing_during_validation_drops_the_winner( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.factor = staticmethod(lambda name, call: 0.8 if name == "spin/4" else 1.0) + + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + # The winner's windows fail once the rounds are over. + if ( + self.candidate.name == "spin/4" + and self.calls >= _windows("standard") + and (self.pending.get("op") == "time") + ): + raise controller._ChildError("spin/4 exited") # pyright: ignore[reportPrivateUsage] + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + report = calibrate("standard", environ=environ, cpus=range(8)) + assert report.status == "published" + assert report.chosen == report.builtin + validation = report.evidence["validation"] + assert isinstance(validation, dict) + assert validation["passed"] is False + + +def test_an_unreadable_or_equal_row_is_replaced_on_publication( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + _, environ = scripted + first = calibrate("quick", environ=environ, cpus=range(8)) + assert first.path is not None + record = json.loads(first.path.read_text(encoding="utf-8")) + record["rows"].append(dict(record["rows"][0], allocation={"cores": "many"})) + first.path.write_text(json.dumps(record), encoding="utf-8") + with pytest.warns(_native.store.StoreWarning, match="not an allocation class"): + calibrate("quick", environ=environ, cpus=range(8)) + rows = _rows(environ) + assert len(rows) == 1 + + +def test_a_childs_standard_error_keeps_its_last_lines() -> None: + import io # noqa: PLC0415 + import types # noqa: PLC0415 + + real = controller._Child # pyright: ignore[reportPrivateUsage] + child = real.__new__(real) + child.errors = [] + lines = "".join(f"line {index}\n" for index in range(30)) + child.process = types.SimpleNamespace(stderr=io.StringIO(lines)) # type: ignore[assignment] + child._drain() # pyright: ignore[reportPrivateUsage] + assert child.errors == [f"line {index}" for index in range(10, 30)] + + +def test_admitted_grain_settings_are_validated_and_published( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.by_record = staticmethod( + lambda record, call: 0.8 if record and record["medium_threads"] == 2 else 1.0 + ) + report = calibrate("standard", environ=environ, cpus=range(8)) + assert report.status == "published" + assert report.chosen["medium_threads"] == 2 + assert report.builtin["medium_threads"] == 4 + assert "grain: admitted: medium 2" in report.reason + (row,) = _rows(environ) + assert row["workspace"] == {"medium_threads": 2} + evidence = row["evidence"] + assert isinstance(evidence, dict) + grain_evidence = evidence["grain"] + assert isinstance(grain_evidence, dict) + assert grain_evidence["validation"]["passed"] is True # pyright: ignore[reportIndexIssue, reportUnknownMemberType] + + +def _windows(level: str) -> int: + """Return the windows one candidate times in a level's rounds of every problem.""" + return controller.LEVELS[level].rounds * len(controller.LEVELS[level].problems) + + +def test_a_grain_that_fails_its_validation_is_dropped( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + timed: list[int] = [] + + def by_record(record: Mapping[str, object] | None, call: int) -> float: + if record and record["medium_threads"] == 2: + timed.append(call) + # Faster in its rounds, slower once every window is timed together. + return 0.8 if len(timed) <= _windows("standard") else 1.3 + return 1.0 + + script.by_record = staticmethod(by_record) + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "medium_threads" not in report.chosen + assert "the combined grain failed its validation" in report.reason + + +def test_the_grain_reports_why_it_kept_the_table( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + # No candidate wins. + report = calibrate("quick", environ=environ, cpus=range(8)) + assert "grain: no grain candidate won every round" in report.reason + # No operation a candidate could move, and no compact problem. + script.sizing = {} + script.shape = [1, 4, 1, 4096] + report = calibrate("quick", environ=environ, cpus=range(8)) + assert "grain: no candidate changes an operation's width" in report.reason + + +def test_a_noisy_or_failed_grain_keeps_the_table( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + script, environ = scripted + measured = controller.selection.noise + calls: list[int] = [] + + def noise(builtin: object, again: object, minimum: int) -> float | None: + # The process session's noise, then the grain session's: far above the limit. + calls.append(minimum) + return measured(builtin, again, minimum) if len(calls) == 1 else 0.5 # type: ignore[arg-type] + + monkeypatch.setattr(controller.selection, "noise", noise) + noisy = calibrate("standard", environ=environ, cpus=range(8)) + assert "grain: inconclusive grain noise" in noisy.reason + monkeypatch.setattr(controller.selection, "noise", measured) + + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + if self.pending.get("op") == "sizing": + raise controller._ChildError("built-in exited") # pyright: ignore[reportPrivateUsage] + if self.pending.get("op") == "time" and self.pending.get("record"): + raise controller._ChildError("built-in exited") # pyright: ignore[reportPrivateUsage] + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + failed = calibrate("quick", environ=environ, cpus=range(8)) + assert "grain: built-in exited" in failed.reason + + +def test_a_failed_grain_window_is_dropped( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + record = self.pending.get("record") + if isinstance(record, dict) and record.get("compact_threads") == 4: + raise controller._ChildError("compact window failed") # pyright: ignore[reportPrivateUsage] + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + report = calibrate("quick", environ=environ, cpus=range(8)) + evidence = report.evidence["grain"] + assert isinstance(evidence, dict) + verdicts = evidence["verdicts"] + assert isinstance(verdicts, dict) + assert verdicts["compact 4"]["reason"] == "0 of 3 rounds" # pyright: ignore[reportUnknownMemberType] + + +def test_a_candidate_that_changes_no_problem_is_left_out( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + script, environ = scripted + script.shape = [1, 4, 1, 4096] + monkeypatch.setattr(controller.grain, "affected", lambda *args: False) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert "grain: no candidate changes an operation's width" in report.reason + + +def test_a_grain_out_of_time_keeps_the_table( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + script, environ = scripted + sized: list[bool] = [] + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + if self.pending.get("op") == "sizing": + sized.append(True) + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + deadline = controller._Deadline # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr(deadline, "remaining", lambda self: 0.0 if sized else 1e9) + report = calibrate("quick", environ=environ, cpus=range(8)) + assert "grain: inconclusive grain noise" in report.reason + + +def test_a_failed_validation_window_fails_the_grain( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.by_record = staticmethod( + lambda record, call: 0.8 if record and record["medium_threads"] == 2 else 1.0 + ) + windows: list[int] = [] + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + record = self.pending.get("record") + if isinstance(record, dict) and record.get("medium_threads") == 2: + windows.append(1) + if len(windows) > _windows("standard"): + raise controller._ChildError("validation window failed") # pyright: ignore[reportPrivateUsage] + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "the combined grain failed its validation" in report.reason + + +def _efforts(planning: Mapping[str, object] | None) -> dict[str, str]: + if not planning: + return {} + return cast(dict[str, str], planning["efforts"]) + + +def test_the_fft_stage_publishes_nothing_without_a_winner( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + # Wisdom planned alone is not assumed neutral in the step: without an effort that + # beats the plan policy, a run plans as it would without a profile. + _, environ = scripted + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "FFT planning: the plan policy: no effort won" in report.reason + (row,) = _rows(environ) + assert not row.get("planning") + generation = json.loads(cast(Path, report.path).read_text(encoding="utf-8")) + assert not generation.get("wisdom") + + +def test_planning_candidates_are_compared_with_the_built_in_plan_policy( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + seen: list[Mapping[str, object] | None] = [] + + def by_planning(planning: Mapping[str, object] | None, call: int) -> float: + seen.append(planning) + return 1.0 + + script.by_planning = staticmethod(by_planning) + calibrate("standard", environ=environ, cpus=range(8)) + # The FFT stage's base is the policy's efforts planned in place, with no stored + # wisdom: never the children's estimate default, nor the policy with wisdom. + base = {"mode": "profile", "efforts": dict(controller.POLICY), "wisdom": {}} + assert base in seen + assert all( + planning is None or planning == base or _efforts(planning) != controller.POLICY + for planning in seen + ) + + +def test_an_admitted_effort_is_validated_and_its_wisdom_kept( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.by_planning = staticmethod( + lambda planning, call: ( + 0.8 if _efforts(planning).get("threaded_rows") == "measure" else 1.0 + ) + ) + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "FFT planning: admitted: threaded_rows measure" in report.reason + (row,) = _rows(environ) + assert row["planning"] == { + "efforts": controller.POLICY | {"threaded_rows": "measure"} + } + generation = json.loads(cast(Path, report.path).read_text(encoding="utf-8")) + assert set(generation["wisdom"]) == set(script.plans) + + +def test_patient_serial_plans_are_tried_at_thorough( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.by_planning = staticmethod( + lambda planning, call: ( + 0.8 if _efforts(planning).get("rows") == "patient" else 1.0 + ) + ) + report = calibrate("thorough", environ=environ, cpus=range(8)) + assert "rows patient" in report.reason + generation = json.loads(cast(Path, report.path).read_text(encoding="utf-8")) + key = "r2c:4096:1:4096:2049:out:0:0:t1" + assert generation["wisdom"][key] == f"(patient {key})" + + +def test_quick_leaves_the_plan_policy_and_a_failed_capture_keeps_it( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + quick = calibrate("quick", environ=environ, cpus=range(8)) + assert "FFT planning: the plan policy (quick)" in quick.reason + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + if self.pending.get("op") == "capture": + raise controller._ChildError("built-in exited") # pyright: ignore[reportPrivateUsage] + return original(self, timeout) + + script.receive = receive # type: ignore[method-assign] + failed = calibrate("standard", environ=environ, cpus=range(8)) + assert "FFT planning: built-in exited" in failed.reason + + +def test_an_effort_that_fails_its_validation_keeps_the_policy( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + seen: list[int] = [] + + def by_planning(planning: Mapping[str, object] | None, call: int) -> float: + if _efforts(planning).get("threaded_rows") != "measure": + return 1.0 + seen.append(call) + return 0.8 if len(seen) <= _windows("standard") else 1.3 + + script.by_planning = staticmethod(by_planning) + script.plans = {"c2c-forward:4096:4:4096:4096:out:0:0:t8": "threaded_rows"} + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "FFT planning: the plan policy" in report.reason + evidence = report.evidence["planning"] + assert isinstance(evidence, dict) + assert evidence["validation"]["passed"] is False # pyright: ignore[reportIndexIssue, reportUnknownMemberType] + + +def test_a_capture_that_plans_nothing_leaves_the_plan_policy( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.plans = {} + report = calibrate("standard", environ=environ, cpus=range(8)) + assert ( + "FFT planning: no transform planned within the stage's share" in report.reason + ) + + +def test_planes_and_a_group_in_no_problem_are_handled( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.plans = {"planes-forward:64x64:2:4096:4096:in:0:0:t8": "planes"} + original = script.receive + + def receive(self: _Scripted, timeout: float) -> object: + reply = original(self, timeout) + if self.pending.get("op") == "capture": + # No problem holds the transform: nothing to time. + assert isinstance(reply, dict) + reply["problems"] = {} + return reply + + script.receive = receive # type: ignore[method-assign] + report = calibrate("standard", environ=environ, cpus=range(8)) + evidence = report.evidence["planning"] + assert isinstance(evidence, dict) + assert evidence["candidates"] == ["planes estimate"] + assert "noise" not in evidence + + +def test_the_grain_stage_leaves_fft_planning_its_share( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + # A thorough grain stage once spent the whole budget and left planning none. + _, environ = scripted + grain, planning = controller._grain, controller._planning # pyright: ignore[reportPrivateUsage] + limits: dict[str, float] = {} + + def recorded(name: str, stage: Any) -> Any: + def wrapper(plan: Any, *args: Any) -> Any: + limits[name] = args[-1] + limits[f"{name} left"] = plan.deadline.remaining() + return stage(plan, *args) + + return wrapper + + monkeypatch.setattr(controller, "_grain", recorded("grain", grain)) + monkeypatch.setattr(controller, "_planning", recorded("planning", planning)) + calibrate("standard", environ=environ, cpus=range(8)) + level = controller.LEVELS["standard"] + reserve = level.budget * level.reserve + assert limits["planning"] == pytest.approx(reserve) + share = (limits["grain left"] - reserve) * level.planning + assert limits["grain"] == pytest.approx(reserve + share, rel=1e-3) + assert controller.LEVELS["quick"].planning == 0.0 + + +def test_captures_take_a_share_of_the_stage_and_an_incomplete_one_is_dropped( + scripted: tuple[type[_Scripted], dict[str, str]], monkeypatch: pytest.MonkeyPatch +) -> None: + # On 16 cores of an AMD EPYC 9B45 the thorough captures outran the deadline. + script, environ = scripted + original = script.receive + budgets: list[float] = [] + + def receive(self: _Scripted, timeout: float) -> object: + reply = original(self, timeout) + if self.pending.get("op") == "capture": + budgets.append(float(cast(float, self.pending["budget"]))) + assert timeout <= budgets[-1] + 30.0 + assert isinstance(reply, dict) + if self.pending["effort"] == "patient": + reply["skipped"] = ["radial"] + return reply + + monkeypatch.setattr(script, "receive", receive) + script.by_planning = staticmethod( + lambda planning, call: ( + 0.8 if _efforts(planning).get("rows") == "patient" else 1.0 + ) + ) + report = calibrate("thorough", environ=environ, cpus=range(8)) + assert len(budgets) == 2 + assert 0.0 < budgets[0] < controller.LEVELS["thorough"].budget + # The incomplete patient capture is no candidate, however fast it would be. + assert "rows patient" not in report.reason + # An incomplete measure capture keeps the plan policy. + budgets.clear() + + def short(self: _Scripted, timeout: float) -> object: + reply = original(self, timeout) + if self.pending.get("op") == "capture": + assert isinstance(reply, dict) + reply["skipped"] = ["line"] + return reply + + monkeypatch.setattr(script, "receive", short) + kept = calibrate( + "standard", + environ={**environ, "SOLPROP_CACHE_DIR": environ["SOLPROP_CACHE_DIR"] + "2"}, + cpus=range(8), + ) + # A measure capture cut short keeps what it planned and times those problems. + planning = cast(dict[str, object], kept.evidence["planning"]) + assert planning["skipped"] == ["line"] + assert "planning at measure took longer" not in kept.reason + + +def test_a_team_that_slows_a_production_sized_problem_is_not_admitted( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + # On 16 cores of a Google Axion a team of 8 won the small problems and ran the + # tracking set's large cases up to 1.8 times slower. + script, environ = scripted + script.by_problem = staticmethod( + lambda name, problem, record: ( + (1.5 if problem == "radial_large" else 0.7) if name == "spin/4" else 1.0 + ) + ) + report = calibrate("standard", environ=environ, cpus=range(8)) + assert report.chosen == report.builtin + verdicts = cast(dict[str, dict[str, object]], report.evidence["verdicts"]) + verdict = verdicts["spin/4"] + assert verdict["admitted"] is False + assert str(verdict["reason"]).startswith("slower on radial_large") + windows = cast(dict[str, float], verdict["windows"]) + assert windows["radial_large"] == pytest.approx(1.5) + assert set(windows) == set(controller.LEVELS["standard"].problems) + + +def test_a_grain_move_that_slows_one_problem_is_not_admitted( + scripted: tuple[type[_Scripted], dict[str, str]], +) -> None: + script, environ = scripted + script.by_problem = staticmethod( + lambda name, problem, record: ( + (1.2 if problem == "capillary_large" else 0.6) + if record and record["medium_threads"] == 2 + else 1.0 + ) + ) + report = calibrate("standard", environ=environ, cpus=range(8)) + assert "medium_threads" not in report.chosen + grain = cast(dict[str, object], report.evidence["grain"]) + verdict = cast(dict[str, dict[str, object]], grain["verdicts"])["medium 2"] + assert str(verdict["reason"]).startswith("slower on capillary_large") + + +def test_standard_and_thorough_time_production_sized_problems() -> None: + for level in ("standard", "thorough"): + problems = controller.LEVELS[level].problems + assert {"gnlse_large", "capillary_large", "radial_large", "line_large"} <= set( + problems + ) + assert not any(p.endswith("_large") for p in controller.LEVELS["quick"].problems) diff --git a/tests/calibration/test_first_use.py b/tests/calibration/test_first_use.py new file mode 100644 index 000000000..1edf25c1e --- /dev/null +++ b/tests/calibration/test_first_use.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Calibrating on first use, before a scan's workers, and never twice (SEP 46 §7.14).""" + +from __future__ import annotations + +import json +import socket +import time +from pathlib import Path + +import pytest + +from solprop import calibration +from solprop.calibration import _native, controller, first_use, problems +from solprop.exceptions import CalibrationNotice + + +@pytest.fixture +def auto( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> list[tuple[str, tuple[int, ...]]]: + """An empty store under SOLPROP_CALIBRATION=auto; calibrations are recorded.""" + monkeypatch.setenv("SOLPROP_CALIBRATION", "auto") + monkeypatch.setenv("SOLPROP_CACHE_DIR", str(tmp_path / "store")) + monkeypatch.delenv("SOLPROP_CALIBRATION_ROLE", raising=False) + monkeypatch.setattr(first_use, "_DONE", set()) + monkeypatch.setattr(first_use, "_device_build", lambda: False) + asked: list[tuple[str, tuple[int, ...]]] = [] + + def fake( + level: str, budget: object = None, *, environ: object, cpus: object + ) -> object: + asked.append((level, tuple(cpus))) # type: ignore[arg-type] + return controller.CalibrationReport( + level=level, status="published", fingerprint="f", allocation={} + ) + + monkeypatch.setattr(controller, "calibrate", fake) + return asked + + +def _publish( + level: str, cpus: tuple[int, ...], *, before: tuple[object, ...] = () +) -> None: + """Publish a row of `level` for the class of `cpus` on this machine and build. + + Rows in `before` come first in the generation. + """ + found = calibration.choice() + assert found.fingerprint is not None + machine = _native.topology.read_topology() + allocation = _native.allocation.classify( + machine, + cpus, + first_use._process.cgroup_cpus(), # pyright: ignore[reportPrivateUsage] + ) + store = _native.store + store.Store(calibration.store_directory()).publish( + store.Generation( + fingerprint=found.fingerprint.digest, + name=store.generation_name(), + created="2026-11-08", + rows=(*before, store.Row(allocation=allocation.record(), level=level)), # type: ignore[arg-type] + ) + ) + + +def test_a_first_native_run_calibrates_quickly_once_with_a_notice( + auto: list[tuple[str, tuple[int, ...]]], +) -> None: + with pytest.warns(CalibrationNotice, match="calibrating the native backend"): + report = first_use.ensure() + assert report is not None + assert [level for level, _ in auto] == ["quick"] + # Not twice in one process. + assert first_use.ensure() is None + assert len(auto) == 1 + + +def test_a_process_that_loaded_native_refreshes_its_profile( + auto: list[tuple[str, tuple[int, ...]]], monkeypatch: pytest.MonkeyPatch +) -> None: + import solprop_native # noqa: PLC0415 # pyright: ignore[reportMissingImports] + + refreshed: list[bool] = [] + monkeypatch.setattr( + solprop_native, "refresh_profile", lambda: refreshed.append(True) + ) + with pytest.warns(CalibrationNotice): + first_use.ensure() + assert refreshed == [True] + + +@pytest.mark.parametrize( + ("variable", "value"), + [ + ("SOLPROP_CALIBRATION", "use"), + ("SOLPROP_CALIBRATION", "off"), + ("SOLPROP_CALIBRATION_ROLE", "worker"), + ], +) +def test_use_off_and_workers_never_measure( + auto: list[tuple[str, tuple[int, ...]]], + monkeypatch: pytest.MonkeyPatch, + variable: str, + value: str, +) -> None: + monkeypatch.setenv(variable, value) + assert first_use.ensure() is None + assert first_use.for_workers([0]) is None + assert auto == [] + + +def test_a_single_cpu_is_never_measured( + auto: list[tuple[str, tuple[int, ...]]], +) -> None: + # A single-threaded scan's workers: nothing to choose, so no notice and no wait. + assert first_use.for_workers([0]) is None + assert auto == [] + + +def test_a_device_build_is_left_to_its_own_calibration( + auto: list[tuple[str, tuple[int, ...]]], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(first_use, "_device_build", lambda: True) + assert first_use.ensure() is None + assert auto == [] + + +def test_an_existing_row_is_used_and_a_scan_wants_a_standard_one( + auto: list[tuple[str, tuple[int, ...]]], +) -> None: + cpus = tuple(first_use._process.allowed_cpus()) # pyright: ignore[reportPrivateUsage] + _publish("quick", cpus) + assert first_use.ensure() is None + # A quick row is not enough for a scan's workers: they calibrate at standard. + with pytest.warns(CalibrationNotice, match="standard"): + first_use.for_workers(cpus) + assert auto == [("standard", cpus)] + _publish("standard", cpus) + first_use._DONE.clear() # pyright: ignore[reportPrivateUsage] + assert first_use.for_workers(cpus) is None + + +def test_a_malformed_row_is_passed_over( + auto: list[tuple[str, tuple[int, ...]]], +) -> None: + cpus = tuple(first_use._process.allowed_cpus()) # pyright: ignore[reportPrivateUsage] + broken = _native.store.Row(allocation={"cores": "four"}, level="standard") + _publish("standard", cpus, before=(broken,)) + with pytest.warns(_native.store.StoreWarning, match="ignoring a calibration row"): + assert first_use.for_workers(cpus) is None + assert auto == [] + + +def test_a_recent_inconclusive_attempt_waits_a_day( + auto: list[tuple[str, tuple[int, ...]]], +) -> None: + found = calibration.choice() + assert found.fingerprint is not None and found.allocation is not None + store = _native.store.Store(calibration.store_directory()) + store.record_inconclusive(found.fingerprint.digest, found.allocation.key()) + assert first_use.ensure() is None + store.record_inconclusive( + found.fingerprint.digest, + found.allocation.key(), + when=time.time() - first_use.RETRY_SECONDS - 1, + ) + with pytest.warns(CalibrationNotice): + first_use.ensure() + assert len(auto) == 1 + + +def test_concurrent_first_runs_wait_for_the_calibrating_one( + auto: list[tuple[str, tuple[int, ...]]], monkeypatch: pytest.MonkeyPatch +) -> None: + found = calibration.choice() + assert found.fingerprint is not None and found.allocation is not None + store = _native.store.Store(calibration.store_directory()) + claim = store.claim(found.fingerprint.digest, found.allocation.key(), 1e12) + assert claim is not None + waited: list[float] = [] + + def sleep(seconds: float) -> None: + waited.append(seconds) + claim.release() + + monkeypatch.setattr(first_use.time, "sleep", sleep) + assert first_use.ensure() is None + assert waited + assert auto == [] + # Another host's claim: proceed on the table without calibrating or waiting. + path = store.directory(found.fingerprint.digest) / f"claim-{found.allocation.key()}" + path.write_text( + json.dumps( + {"host": socket.gethostname() + "-other", "pid": 1, "deadline": 1e12} + ), + encoding="utf-8", + ) + first_use._DONE.clear() # pyright: ignore[reportPrivateUsage] + assert first_use.ensure() is None + assert auto == [] + + +def test_the_build_record_says_whether_the_build_runs_on_a_device( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + detect = first_use.__dict__["_device_build"] + cpu, gpu, empty = tmp_path / "cpu", tmp_path / "gpu", tmp_path / "empty" + for directory, backend in ((cpu, "none"), (gpu, "cuda")): + directory.mkdir() + (directory / "_build.json").write_text( + json.dumps({"device_backend": backend}), encoding="utf-8" + ) + empty.mkdir() + monkeypatch.setattr(_native, "packages", lambda: (empty, cpu)) + assert detect() is False + monkeypatch.setattr(_native, "packages", lambda: (gpu,)) + assert detect() is True + monkeypatch.setattr(_native, "packages", lambda: (empty,)) + assert detect() is False + + +def test_selecting_native_and_admitting_a_scan_ask_for_a_calibration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solprop.backends import registry # noqa: PLC0415 + from solprop.driver.executors import pool # noqa: PLC0415 + + asked: list[str] = [] + monkeypatch.setattr(first_use, "ensure", lambda: asked.append("ensure")) + + class StopError(Exception): + pass + + def stop(*args: object) -> None: + raise StopError + + monkeypatch.setattr(registry, "_check_required_device", stop) + with pytest.raises(StopError): + registry.select("native", problems.build("gnlse").translate()) + assert asked == ["ensure"] + monkeypatch.setattr(first_use, "for_workers", lambda cpus: asked.append("workers")) + monkeypatch.setattr(pool, "_spawn", stop) + with pytest.raises(StopError): + pool.Pool(threads=1)._admit(lambda: None, None) # pyright: ignore[reportPrivateUsage] + assert asked == ["ensure", "workers"] + + +def test_without_the_native_backend_first_use_does_nothing( + auto: list[tuple[str, tuple[int, ...]]], monkeypatch: pytest.MonkeyPatch +) -> None: + from solprop.exceptions import BackendError # noqa: PLC0415 + + def missing() -> tuple[Path, ...]: + raise BackendError("install solprop-native") + + monkeypatch.setattr(_native, "packages", missing) + assert first_use.ensure() is None + assert first_use.for_workers([0]) is None + assert auto == [] + + +def test_a_store_it_cannot_write_leaves_the_run_on_the_table( + auto: list[tuple[str, tuple[int, ...]]], monkeypatch: pytest.MonkeyPatch +) -> None: + # The calibration is optional: a read-only or missing store must not stop a run. + def refuse(*args: object, **kwargs: object) -> object: + raise PermissionError("read-only file system") + + monkeypatch.setattr(controller, "calibrate", refuse) + with pytest.warns(CalibrationNotice) as caught: + assert first_use.ensure() is None + assert any("could not use its calibration store" in str(w.message) for w in caught) + # Once per process: a second run neither retries nor warns again. + assert first_use.ensure() is None diff --git a/tests/calibration/test_grain.py b/tests/calibration/test_grain.py new file mode 100644 index 000000000..cb9b484bc --- /dev/null +++ b/tests/calibration/test_grain.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Operation-grain candidates from the problems' work sizes (SEP 46 phase 2).""" + +from __future__ import annotations + +from solprop.calibration import _native +from solprop.calibration.grain import ( + TIMED, + Candidate, + Operation, + affected, + best, + operations, + proposals, + width, +) +from solprop.calibration.selection import Verdict + +TABLE = _native.execution.builtin("Linux").native() + + +def test_sizing_keys_parse_into_operations() -> None: + found = operations( + { + "kernels.stage.4096": {}, + "kernels.response.98304.samples.4096": {}, + "kernels.odd": {}, + "kernels.stage.many": {}, + } + ) + assert found == [ + Operation("kernels", "response", 98304, 4096), + Operation("kernels", "stage", 4096), + ] + + +def test_widths_follow_the_native_rule() -> None: + stage = Operation("kernels", "stage", 4096) + assert width(TABLE, stage, 18) == 18 + assert width(TABLE, Operation("kernels", "stage", 100), 18) == 1 + copy = Operation("kernels", "copy", 65536) + assert width(TABLE, copy, 18) == 18 + response = Operation("kernels", "response", 33 * 4096, 4096) + assert width(TABLE, response, 18) == 4 + # Forty columns or 65536 samples take the full pool above the threshold. + assert width(TABLE, Operation("kernels", "response", 40 * 1024, 1024), 18) == 18 + assert width(TABLE, Operation("kernels", "response", 65536, 65536), 18) == 18 + assert width(TABLE, Operation("kernels", "response", 40000, 0), 18) == 4 + assert width(TABLE, Operation("kernels", "response", 1000, 1000), 18) == 1 + + +def test_a_record_affects_a_problem_only_where_a_width_moves() -> None: + found = [Operation("kernels", "stage", 4096)] + assert not affected(dict(TABLE, stage_threshold=2048), TABLE, found, 18) + assert affected( + dict(TABLE, stage_threshold=4097, stage_full=4097), TABLE, found, 18 + ) + + +def test_each_class_moves_one_step_past_its_nearest_sizes() -> None: + record = dict(TABLE, stage_threshold=1000, stage_full=10000) + problems = { + "a": [Operation("kernels", "stage", size) for size in (500, 5000, 50000)] + } + found = proposals(record, problems, 18) + names = [candidate.name for candidate in found] + assert names == [ + "medium 2", + "medium 8", + "stage threshold 500", + "stage threshold 5001", + "stage full 5000", + "stage full 50001", + ] + moved = found[2].record(record) + assert (moved["stage_threshold"], moved["stage_full"]) == (500, 10000) + # Thresholds only, and a limit. + assert [c.name for c in proposals(record, problems, 18, fulls=False)][2:] == [ + "stage threshold 500", + "stage threshold 5001", + ] + assert len(proposals(record, problems, 18, limit=3)) == 3 + + +def test_the_medium_width_and_the_compact_team_have_short_ladders() -> None: + problems = {"a": [Operation("kernels", "stage", 4096)]} + # Every operation at full width: a medium width changes nothing. + assert [c.setting for c in proposals(TABLE, problems, 18)] == ["stage", "stage"] + compact = proposals(TABLE, problems, 18, compact=["a"]) + assert [c.name for c in compact][:2] == ["compact 4", "compact 16"] + # On four threads the compact team of eight is already the whole pool. + small = proposals(TABLE, problems, 4, compact=["a"]) + assert [c.name for c in small if c.setting == "compact"] == [] + # Classes outside the timed windows keep the table's values. + adams = {"a": [Operation("kernels", "adams", 4096)]} + assert proposals(TABLE, adams, 18) == [] + assert "adams" not in TIMED + # Nor are the transforms' thresholds moved: counted in elements, transforms of one + # count thread oppositely by shape (SEP 46 Part II). + transforms = { + "a": [Operation("fft", "fft", 98304, 768), Operation("fft", "fft", 131072, 512)] + } + assert [c for c in proposals(TABLE, transforms, 18) if "fft" in c.name] == [] + assert "fft" not in TIMED + + +def test_the_best_admitted_candidate_of_each_setting_is_kept() -> None: + def verdict(score: float | None, admitted: bool) -> Verdict: + return Verdict(admitted, score, score, 3, "") + + candidates = [ + Candidate("medium", "medium 2", {"medium_threads": 2}), + Candidate("medium", "medium 8", {"medium_threads": 8}), + Candidate("stage", "stage threshold 1", {"stage_threshold": 1}), + Candidate("fft", "fft threshold 1", {"fft_threshold": 1}), + ] + verdicts = { + "medium 2": verdict(0.9, True), + "medium 8": verdict(0.8, True), + "stage threshold 1": verdict(1.2, False), + "fft threshold 1": verdict(None, True), + } + assert [c.name for c in best(candidates, verdicts)] == ["medium 8"] + # A later candidate of the same setting with a worse score does not replace it. + worse = {**verdicts, "medium 2": verdict(0.7, True), "medium 8": verdict(0.8, True)} + assert [c.name for c in best(candidates, worse)] == ["medium 2"] diff --git a/tests/calibration/test_profiles.py b/tests/calibration/test_profiles.py new file mode 100644 index 000000000..91d6d4c55 --- /dev/null +++ b/tests/calibration/test_profiles.py @@ -0,0 +1,361 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Reading, showing, exporting, importing and clearing machine profiles (SEP 46).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from solprop import calibration +from solprop.calibration import _native, command +from solprop.cli import main +from solprop.exceptions import BackendError, CalibrationError + + +@pytest.fixture +def environ(tmp_path: Path) -> dict[str, str]: + """An environment whose store is empty and whose calibration is on.""" + return {"SOLPROP_CACHE_DIR": str(tmp_path / "store"), "SOLPROP_CALIBRATION": "use"} + + +@pytest.fixture +def cli_environ( + environ: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> dict[str, str]: + """The same environment, set on the process for the command line.""" + for name, value in environ.items(): + monkeypatch.setenv(name, value) + monkeypatch.delenv("SOLPROP_CALIBRATION_PATH", raising=False) + return environ + + +def _publish(environ: dict[str, str], *, exact: bool = True) -> str: + """Publish a generation for this machine and build; return its fingerprint.""" + found = calibration.choice(environ) + assert found.fingerprint is not None and found.allocation is not None + allocation = found.allocation.record() + if not exact: + assert isinstance(allocation["cores"], int) + if allocation["cores"] < 2: + pytest.skip("needs two cores for a smaller allocation") + allocation["cores"] = allocation["cores"] - 1 + kinds = allocation["kinds"] + assert isinstance(kinds, list) + kinds[0] -= 1 + store = _native.store + store.Store(calibration.store_directory(environ)).publish( + store.Generation( + fingerprint=found.fingerprint.digest, + name="g1", + created="2026-11-08T12:00:00Z", + rows=( + store.Row( + allocation=allocation, + level="standard", + process={"threads": 2, "wait": "spin"}, + workspace={"stage_threshold": 1024, "stable_kernel_team": False}, + fitted_under={"threads": 2, "wait": "spin"}, + evidence={"geomean": 0.91}, + ), + ), + ) + ) + return found.fingerprint.digest + + +def test_the_pure_modules_load_without_the_native_extension() -> None: + assert _native.store.MODES == ("auto", "use", "off") + assert _native.variant().startswith("_core") + assert _native.packages() + with pytest.raises(AttributeError, match="no attribute 'extension'"): + _native.__getattr__("extension") + + +def test_without_the_native_package_the_store_cannot_be_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_native.importlib.util, "find_spec", lambda name: None) + with pytest.raises(BackendError, match="install solprop-native"): + _native.packages() + + +def test_no_profile_means_the_built_in_table(environ: dict[str, str]) -> None: + assert calibration.profile(environ) is None + assert calibration.profile(environ | {"SOLPROP_CALIBRATION": "off"}) is None + with pytest.raises(CalibrationError, match="no calibration profile"): + calibration.export_profile(Path("unused.json"), environ) + assert calibration.clear(environ) == 0 + assert calibration.clear(environ | {"SOLPROP_CALIBRATION": "off"}) == 0 + text = command.describe(environ) + assert "no profile for this machine and build" in text + assert "SOLPROP_CALIBRATION=use" in text + off = command.describe(environ | {"SOLPROP_CALIBRATION": "off"}) + assert "profile: none (the built-in table applies)" in off + + +def test_a_published_profile_is_read_shown_exported_and_cleared( + environ: dict[str, str], tmp_path: Path +) -> None: + digest = _publish(environ) + found = calibration.profile(environ) + assert found is not None + assert (found.fingerprint, found.generation, found.level) == ( + digest, + "g1", + "standard", + ) + assert found.exact + assert found.workspace["stage_threshold"] == 1024 + assert found.source is not None and found.source.name == "g1.json" + text = command.describe(environ) + assert "standard, generation g1" in text + assert "measured on this allocation" in text + assert "stage_threshold 1024 (built-in" in text + assert "stable_kernel_team no (built-in" in text + # Export, clear, then import back. + exported = tmp_path / "node.json" + assert calibration.export_profile(exported, environ) == found.source + assert json.loads(exported.read_text(encoding="utf-8"))["fingerprint"] == digest + assert calibration.clear(environ) == 2 + assert calibration.profile(environ) is None + assert calibration.import_profile(exported, environ) is True + assert calibration.profile(environ) is not None + + +def test_a_profile_for_a_smaller_allocation_supplies_workspace_settings( + environ: dict[str, str], +) -> None: + _publish(environ, exact=False) + found = calibration.profile(environ) + assert found is not None + assert not found.exact + assert "workspace settings only" in command.describe(environ) + + +def test_another_machines_profile_is_imported_but_does_not_apply( + environ: dict[str, str], tmp_path: Path +) -> None: + foreign = tmp_path / "foreign.json" + foreign.write_text( + json.dumps( + { + "format": 1, + "fingerprint": "0" * 24, + "name": "g1", + "created": "2026-11-08", + "rows": [], + } + ), + encoding="utf-8", + ) + assert calibration.import_profile(foreign, environ) is False + broken = tmp_path / "broken.json" + broken.write_text("{", encoding="utf-8") + with pytest.raises(CalibrationError, match="cannot import"): + calibration.import_profile(broken, environ) + + +def test_the_command_line_shows_exports_imports_and_clears( + cli_environ: dict[str, str], + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["calibrate", "--show"]) == 0 + assert "the built-in table applies" in capsys.readouterr().out + exported = tmp_path / "out.json" + assert main(["calibrate", "--export", str(exported)]) == 2 + assert "no calibration profile" in capsys.readouterr().err + _publish(cli_environ) + assert main(["calibrate", "--export", str(exported)]) == 0 + assert f"to {exported}" in capsys.readouterr().out + assert main(["calibrate", "--clear"]) == 0 + assert "removed 2 file(s)" in capsys.readouterr().out + assert main(["calibrate", "--import", str(exported)]) == 0 + assert "applies to this machine and build" in capsys.readouterr().out + foreign = tmp_path / "foreign.json" + record = json.loads(exported.read_text(encoding="utf-8")) + foreign.write_text(json.dumps(record | {"fingerprint": "f" * 24}), encoding="utf-8") + assert main(["calibrate", "--import", str(foreign)]) == 0 + assert "does not apply here" in capsys.readouterr().out + with pytest.raises(SystemExit): + main(["calibrate", "--show", "--clear"]) + + +def test_the_command_line_measures_at_each_level( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from solprop.calibration import controller # noqa: PLC0415 + + asked: list[tuple[str, float | None]] = [] + + def fake( + level: str, budget: float | None, *, progress: object = None + ) -> controller.CalibrationReport: + asked.append((level, budget)) + assert callable(progress) + progress("measuring") + return controller.CalibrationReport( + level=level, + status="published" if level != "thorough" else "inconclusive", + fingerprint="f", + allocation={}, + reason="because", + ) + + monkeypatch.setattr(controller, "calibrate", fake) + assert main(["calibrate"]) == 0 + assert main(["calibrate", "--quick", "--budget", "5"]) == 0 + assert main(["calibrate", "--thorough"]) == 1 + assert asked == [("standard", None), ("quick", 5.0), ("thorough", None)] + captured = capsys.readouterr() + assert "calibration (thorough): inconclusive" in captured.out + assert "solprop calibrate: measuring" in captured.err + + +def test_read_only_directories_are_listed(environ: dict[str, str]) -> None: + shared = Path("/shared/profiles") + text = command.describe(environ | {"SOLPROP_CALIBRATION_PATH": str(shared)}) + assert f"read-only: {shared}" in text + + +def test_a_build_without_its_record_is_still_described( + environ: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + fingerprint = _native.fingerprint + original = fingerprint.build_identity + + def without_record(packages: object, stem: str = "_core") -> dict[str, object]: + return original(packages, stem) | {"record": None} # pyright: ignore[reportArgumentType] + + monkeypatch.setattr(_native.profile, "build_identity", without_record) + assert "no build record" in command.describe(environ) + + +def test_the_summary_names_kinds_quota_and_spans() -> None: + line = command._summary( # pyright: ignore[reportPrivateUsage] + { + "cores": 18, + "kinds": [6, 12], + "sockets": 1, + "numa_nodes": 1, + "l3_groups": 1, + "smt": False, + "quota": 4, + } + ) + assert line == "18 cores, of kinds 6 + 12, quota 4" + + +def test_the_command_does_not_load_the_native_extension(tmp_path: Path) -> None: + import subprocess # noqa: PLC0415 - only here + + script = ( + "import sys; from solprop.cli import main; main(['calibrate', '--show']); " + "print('solprop_native._core' in sys.modules, file=sys.stderr)" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + env={"SOLPROP_CACHE_DIR": str(tmp_path), "PATH": "", "HOME": str(tmp_path)}, + ) + assert completed.stderr.strip().splitlines()[-1] == "False" + + +def test_without_its_processor_check_the_variant_is_the_one_build( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(_native, "packages", lambda: (tmp_path,)) + assert _native.variant() == "_core" + + +def test_a_row_without_process_settings_is_described(environ: dict[str, str]) -> None: + found = calibration.choice(environ) + assert found.fingerprint is not None and found.allocation is not None + store = _native.store + store.Store(calibration.store_directory(environ)).publish( + store.Generation( + fingerprint=found.fingerprint.digest, + name="g1", + created="2026-11-08", + rows=( + store.Row( + allocation=found.allocation.record(), + level="quick", + evidence={ + "reason": "kept", + "noise": 0.02, + "verdicts": {"spin/4": {"score": None, "reason": "1 of 3"}}, + "validation": {"reason": "geometric mean 0.9"}, + }, + ), + ), + ) + ) + text = command.describe(environ) + assert "process:" not in text + assert "fitted under:" not in text + assert "candidate: spin/4 - (1 of 3)" in text + assert "validation: geometric mean 0.9" in text + + +def test_the_summary_names_sockets_nodes_and_smt() -> None: + line = command._summary( # pyright: ignore[reportPrivateUsage] + {"cores": 36, "kinds": [36], "sockets": 2, "numa_nodes": 2, "smt": True} + ) + assert line == "36 cores, 2 sockets, 2 NUMA nodes, SMT" + + +def test_the_package_calibrates_through_its_controller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from solprop.calibration import controller # noqa: PLC0415 + + asked: list[tuple[str, float | None]] = [] + + def fake(level: str, budget: float | None) -> controller.CalibrationReport: + asked.append((level, budget)) + return controller.CalibrationReport( + level=level, status="held", fingerprint="f", allocation={} + ) + + monkeypatch.setattr(controller, "calibrate", fake) + assert calibration.calibrate("quick", 5.0).status == "held" + assert asked == [("quick", 5.0)] + + +def test_the_command_line_refines_a_recipe( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + from solprop import recipe # noqa: PLC0415 + from solprop.calibration import refine # noqa: PLC0415 + + class Built: + def build(self) -> str: + return "simulation" + + monkeypatch.setattr(recipe.Recipe, "load", staticmethod(lambda path: Built())) + asked: list[tuple[object, float]] = [] + + def fake(simulation: object, budget: float, *, progress: object) -> object: + asked.append((simulation, budget)) + assert callable(progress) + progress("refining") + status = "published" if len(asked) == 1 else "inconclusive" + return refine.RefinementReport(status, "abc", reason="because") + + monkeypatch.setattr(refine, "refine", fake) + path = tmp_path / "run.toml" + assert main(["calibrate", "--recipe", str(path)]) == 0 + assert main(["calibrate", "--recipe", str(path), "--budget", "5"]) == 1 + assert asked == [("simulation", 60.0), ("simulation", 5.0)] + captured = capsys.readouterr() + assert "refinement: published" in captured.out + assert "solprop calibrate: refining" in captured.err diff --git a/tests/calibration/test_refine.py b/tests/calibration/test_refine.py new file mode 100644 index 000000000..713d07354 --- /dev/null +++ b/tests/calibration/test_refine.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Stage 2: refining one simulation and reusing it by descriptor (SEP 46 §7.12).""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable +from pathlib import Path + +import numpy as np +import pytest +import solprop_native # pyright: ignore[reportMissingImports] +import solprop_native.backend # pyright: ignore[reportMissingImports] +from solprop_native._profile import ( # pyright: ignore[reportMissingImports] + choose, +) + +from solprop.calibration import _native, grain, problems, refine +from solprop.calibration.refine import RefinementReport, _Windows +from solprop.exceptions import CalibrationError + + +@pytest.fixture +def profiled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """A process that found this machine's (empty) store, as SOLPROP_CALIBRATION=use.""" + store = tmp_path / "store" + monkeypatch.setenv("SOLPROP_CACHE_DIR", str(store)) + found = choose( + {"SOLPROP_CALIBRATION": "use", "SOLPROP_CACHE_DIR": str(store)}, + packages=_native.packages(), + ) + monkeypatch.setattr(solprop_native, "PROFILE", found) + return store + + +def _script( + monkeypatch: pytest.MonkeyPatch, + speed: Callable[[str], float], + drift: Callable[[str], float] = lambda name: 0.0, +) -> None: + """Time each window by its record's medium width; perturb its fields by `drift`.""" + + def name(window: _Windows) -> str: + record = window.backend.metadata()["calibration"] + assert isinstance(record, dict) + medium = record["workspace"]["medium_threads"]["applied"] # type: ignore[index] + return f"medium {medium}" + + def time(window: _Windows, repeats: int) -> float: + return 1e-3 * speed(name(window)) + + def fields(window: _Windows) -> list[np.ndarray]: + return [np.ones(4) * (1.0 + drift(name(window)))] + + monkeypatch.setattr(_Windows, "time", time) + monkeypatch.setattr(_Windows, "fields", fields) + monkeypatch.setattr( + refine, + "_candidates", + lambda problem, sizing, base, threads: [ + grain.Candidate("medium", "medium 2", {"medium_threads": 2}), + grain.Candidate("medium", "medium 8", {"medium_threads": 8}), + ], + ) + + +def test_a_refinement_is_stored_and_a_later_run_applies_it( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _script(monkeypatch, lambda name: 0.8 if name == "medium 2" else 1.0) + simulation = problems.build("gnlse") + lines: list[str] = [] + report = refine.refine(simulation, 30.0, progress=lines.append) + assert report.status == "published" + assert report.changes == {"medium_threads": 2} + assert report.path is not None and report.path.exists() + assert report.seconds > 0 + assert "refinement: published" in report.describe() + assert any("validating" in line for line in lines) + # A later backend of the same execution finds and applies it. + problem = simulation.translate() + backend = solprop_native.backend.NativeBackend( + problem, grid=simulation.grid, modes=simulation.modes + ) + try: + record = backend.metadata()["calibration"] + assert isinstance(record, dict) + assert record["workspace_source"] == f"refinement {report.descriptor}" + assert record["workspace"]["medium_threads"]["applied"] == 2 # type: ignore[index] + finally: + backend.close() + # Another execution (another grid) has another descriptor and applies none. + other = problems.build("gnlse", 2.0) + backend = solprop_native.backend.NativeBackend( + other.translate(), grid=other.grid, modes=other.modes + ) + try: + record = backend.metadata()["calibration"] + assert isinstance(record, dict) + assert not str(record["workspace_source"]).startswith("refinement") + finally: + backend.close() + + +def test_a_refinement_of_another_machine_record_is_not_applied( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _script(monkeypatch, lambda name: 0.8 if name == "medium 2" else 1.0) + simulation = problems.build("gnlse") + report = refine.refine(simulation, 30.0) + assert report.path is not None + # The machine profile changed since: the refinement's base no longer holds. + changed = solprop_native.EXECUTION.updated({"compact_threads": 3}) + monkeypatch.setattr(solprop_native, "EXECUTION", changed) + backend = solprop_native.backend.NativeBackend( + simulation.translate(), grid=simulation.grid, modes=simulation.modes + ) + try: + record = backend.metadata()["calibration"] + assert isinstance(record, dict) + assert not str(record["workspace_source"]).startswith("refinement") + finally: + backend.close() + + +def test_an_inaccurate_candidate_is_never_admitted( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _script( + monkeypatch, + lambda name: 0.5 if name == "medium 2" else 1.0, + drift=lambda name: 1.0 if name == "medium 2" else 0.0, + ) + report = refine.refine(problems.build("gnlse"), 30.0) + assert report.status == "kept" + assert report.reason == "no accurate candidate won" + assert report.evidence["accurate"] == {"medium 2": False, "medium 8": True} + + +def test_a_combination_that_fails_its_validation_is_kept_back( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[str] = [] + + def speed(name: str) -> float: + calls.append(name) + # Faster in its rounds, slower once validated together. + return ( + (0.8 if len([c for c in calls if c == name]) <= 5 else 1.3) + if (name == "medium 2") + else 1.0 + ) + + _script(monkeypatch, speed) + report = refine.refine(problems.build("gnlse"), 30.0) + assert report.status == "kept" + assert report.reason == "the combination's validation failed" + + +def test_an_inaccurate_combination_is_kept_back( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + built: list[int] = [] + _script(monkeypatch, lambda name: 0.8 if name == "medium 2" else 1.0) + original = _Windows.fields + + def fields(window: _Windows) -> list[np.ndarray]: + built.append(1) + # The candidates' windows agree; the combination's, built last, does not. + return [np.ones(4) * (3.0 if len(built) > 3 else 1.0)] + + monkeypatch.setattr(_Windows, "fields", fields) + report = refine.refine(problems.build("gnlse"), 30.0) + assert report.status == "kept" + assert report.reason == "the combination is inaccurate" + assert original is not fields + + +def test_noise_or_no_candidate_or_no_profile_decides_nothing( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def noisy(name: str) -> float: + noisy.calls += 1 # type: ignore[attr-defined] + return 1.5 if noisy.calls % 3 == 0 else 1.0 # type: ignore[attr-defined] + + noisy.calls = 0 # type: ignore[attr-defined] + _script(monkeypatch, noisy) + report = refine.refine(problems.build("gnlse"), 30.0) + assert report.status == "inconclusive" + assert report.reason == "too noisy or too little time to decide" + monkeypatch.setattr(refine, "_candidates", lambda *args: []) + empty = refine.refine(problems.build("gnlse"), 30.0) + assert (empty.status, empty.reason) == ("kept", "no candidate changes a width") + off = choose({"SOLPROP_CALIBRATION": "off"}, packages=_native.packages()) + monkeypatch.setattr(solprop_native, "PROFILE", off) + none = refine.refine(problems.build("gnlse"), 30.0) + assert none.status == "inconclusive" + assert "no profile to refine" in none.reason + + +def test_a_refinement_refuses_a_bad_budget_or_backend() -> None: + simulation = problems.build("gnlse") + with pytest.raises(CalibrationError, match="must be positive"): + refine.refine(simulation, 0.0) + simulation.settings = dataclasses.replace(simulation.settings, backend="numpy_ref") + with pytest.raises(CalibrationError, match="fits the native backend"): + refine.refine(simulation, 1.0) + + +def test_the_real_windows_time_and_compare_trial_steps(profiled: Path) -> None: + simulation = problems.build("gnlse") + base = _Windows(simulation, None) + serial = _Windows( + simulation, + solprop_native.EXECUTION.updated( + {"stage_threshold": 1 << 40, "stage_full": 1 << 40} + ).native(), + ) + try: + assert len(base.positions) == refine.POSITIONS + assert base.time(1) > 0 + reference = base.fields() + # Another width computes the same steps to within the run's tolerance. + assert refine._accurate(serial.fields(), reference, 1e-6) # pyright: ignore[reportPrivateUsage] + assert not refine._accurate([np.full(4, np.nan)], [np.ones(4)], 1e-6) # pyright: ignore[reportPrivateUsage] + assert not refine._accurate([np.ones(4) * 2], [np.ones(4)], 1e-6) # pyright: ignore[reportPrivateUsage] + finally: + base.close() + serial.close() + + +def test_the_report_describes_a_kept_refinement() -> None: + text = RefinementReport("kept", "abc", reason="none won").describe() + assert text.startswith("refinement: kept in 0.0 s (abc)") + assert "refinement:" in text + + +def test_a_low_storage_simulation_times_its_own_march(profiled: Path) -> None: + from solprop.stepper import FixedSchedule, LowStorageRungeKutta # noqa: PLC0415 + + simulation = problems.build("gnlse") + planes = list(simulation.settings.save_planes) + length = float(max(planes)) + simulation.settings = dataclasses.replace( + simulation.settings, + integrator=LowStorageRungeKutta(scheme="rk4ip"), + schedule=FixedSchedule.uniform(0.0, length, length / 10, save_planes=planes), + propagator_mode="tabulate", + ) + window = _Windows(simulation, None) + try: + assert window.positions == [0.0] + assert window.low_storage + assert window.time(1) > 0 + # Its march advances its state: no fields to compare, so nothing is refused. + assert window.fields() == [] + finally: + window.close() + + +def test_candidates_are_the_grain_moves_that_change_the_simulation() -> None: + table = _native.execution.builtin("Linux").native() + + class Problem: + shape = (1, 1, 1, 4096) + + sizing = {"kernels.response.98304.samples.4096": {}} + found = refine._candidates(Problem(), sizing, table, 8) # pyright: ignore[reportPrivateUsage] + names = [candidate.name for candidate in found] + assert "medium 2" in names + assert "compact 4" in names + assert all(not name.startswith("stage") for name in names) + + +def test_a_budget_too_short_for_a_round_is_inconclusive( + profiled: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _script(monkeypatch, lambda name: 1.0) + report = refine.refine(problems.build("gnlse"), 1e-3) + assert report.status == "inconclusive" + assert report.evidence["noise"] is None + + +def test_a_simulation_calibrates_through_stage_two( + monkeypatch: pytest.MonkeyPatch, +) -> None: + asked: list[float] = [] + + def fake(simulation: object, budget: float) -> RefinementReport: + asked.append(budget) + return RefinementReport("kept", "abc") + + monkeypatch.setattr(refine, "refine", fake) + assert problems.build("gnlse").calibrate(12.0).status == "kept" + assert asked == [12.0] diff --git a/tests/calibration/test_selection.py b/tests/calibration/test_selection.py new file mode 100644 index 000000000..0d63df63c --- /dev/null +++ b/tests/calibration/test_selection.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The calibration's selection rule, on deterministic fake timings (SEP 46 §7.5).""" + +from __future__ import annotations + +import pytest + +from solprop.calibration.selection import ( + DROP_ORDER, + MAX_CANDIDATES, + NOISE_FLOOR, + admit, + choose, + fallbacks, + noise, + ratios, + validate, +) + +BUILTIN = [1.0, 1.0, 1.0, 1.0, 1.0] + + +def test_noise_is_the_largest_aa_deviation_and_at_least_the_floor() -> None: + assert noise(BUILTIN, [1.0] * 5, minimum=5) == NOISE_FLOOR + assert noise(BUILTIN, [1.0, 1.06, 0.95, 1.0, 1.0], minimum=5) == 0.06 + # A failed round is dropped; too few complete rounds give no noise at all. + assert noise(BUILTIN, [1.0, None, 1.0, 1.0, 1.0], minimum=4) == NOISE_FLOOR + assert noise(BUILTIN, [1.0, None, 1.0, 1.0, 1.0], minimum=5) is None + + +def test_ratios_drop_failed_and_non_finite_rounds() -> None: + assert ratios([0.5, float("nan"), None, 2.0], [1.0, 1.0, 1.0, 0.0]) == [0.5] + + +def test_a_candidate_must_win_every_round_by_more_than_the_noise() -> None: + clear = admit([0.9, 0.91, 0.89, 0.9, 0.92], BUILTIN, noise=0.02, minimum=5) + assert clear.admitted + assert clear.score == 0.9 + assert clear.worst == 0.92 + # A tie keeps the built-in value. + tie = admit([1.0] * 5, BUILTIN, noise=0.02, minimum=5) + assert not tie.admitted + # Winning on average but not in every round is not enough: one outlier round. + outlier = admit([0.7, 0.7, 0.7, 0.7, 1.01], BUILTIN, noise=0.02, minimum=5) + assert not outlier.admitted + assert outlier.score == 0.7 + # Within the noise is not a win. + marginal = admit([0.97] * 5, BUILTIN, noise=0.04, minimum=5) + assert not marginal.admitted + assert "worst ratio 0.970 against 0.960" in marginal.reason + + +def test_drift_cancels_in_interleaved_pairs() -> None: + # The machine slows by 10 % a round; the candidate stays 8 % faster in each pair. + drifting = [1.0 * 1.1**index for index in range(5)] + candidate = [0.92 * value for value in drifting] + assert admit(candidate, drifting, noise=0.02, minimum=5).admitted + + +def test_missing_rounds_keep_the_built_in() -> None: + verdict = admit([0.5, None, None, 0.5, 0.5], BUILTIN, noise=0.02, minimum=5) + assert not verdict.admitted + assert verdict.score is None + assert verdict.reason == "3 of 5 rounds" + assert verdict.record()["rounds"] == 3 + + +def test_the_admitted_candidate_with_the_lowest_score_is_chosen() -> None: + chosen, verdicts = choose( + { + "spin": [0.8] * 5, + "passive": [0.9] * 5, + "noisy": [0.5, 0.5, 0.5, 0.5, 1.2], + }, + BUILTIN, + noise=0.02, + minimum=5, + ) + assert chosen == "spin" + assert not verdicts["noisy"].admitted + nothing, _ = choose({"slow": [1.1] * 5}, BUILTIN, noise=0.02, minimum=5) + assert nothing is None + with pytest.raises(ValueError, match="at most 4"): + choose( + {str(index): [0.5] * 5 for index in range(MAX_CANDIDATES + 1)}, + BUILTIN, + noise=0.02, + minimum=5, + ) + + +def test_a_combination_passes_only_with_no_slower_window_and_a_gain() -> None: + passed = validate( + {"gnlse": ([0.8] * 3, [1.0] * 3), "radial": ([1.01] * 3, [1.0] * 3)}, + noise=0.02, + minimum=3, + ) + assert passed.passed + assert passed.geomean == pytest.approx((0.8 * 1.01) ** 0.5) + assert passed.record()["windows"] == {"gnlse": 0.8, "radial": 1.01} + level = validate({"a": ([1.0] * 3, [1.0] * 3)}, noise=0.02, minimum=3) + assert not level.passed + assert level.reason == "geometric mean 1.000" + short = validate({"a": ([0.8, None, None], [1.0] * 3)}, noise=0.02, minimum=3) + assert not short.passed + assert short.reason == "a: 1 of 3 rounds" + assert not validate({}, noise=0.02, minimum=3).passed + + +def test_groups_are_dropped_in_a_fixed_order() -> None: + assert DROP_ORDER == ("grain", "fft", "process") + assert fallbacks(["fft", "process"]) == [("fft", "process"), ("process",)] + assert fallbacks([]) == [] + with pytest.raises(ValueError, match="unknown groups"): + fallbacks(["colour"]) + + +def test_a_candidate_slower_on_one_problem_is_not_admitted() -> None: + # A large win on one problem carries the geometric mean of every round, but the + # candidate may not be slower than the noise plus TOLERANCE on any problem. + verdict = admit( + [0.8, 0.82, 0.81], + [1.0, 1.0, 1.0], + 0.02, + 3, + {"small": [0.5, 0.52, 0.51], "large": [1.09, 1.06, 1.1]}, + ) + assert not verdict.admitted + assert verdict.reason == "slower on large: median ratio 1.090 against 1.070" + assert verdict.windows == {"small": 0.51, "large": 1.09} + # Within the bar on every problem: admitted, and a failed round is dropped. + fine = admit( + [0.8] * 3, [1.0] * 3, 0.02, 3, {"small": [0.6] * 3, "large": [1.06, None, 1.04]} + ) + assert fine.admitted + assert fine.record()["windows"] == {"small": 0.6, "large": 1.05} + chosen, verdicts = choose( + {"a": [0.8] * 3, "b": [0.9] * 3}, + [1.0] * 3, + 0.02, + 3, + {"a": {"large": [1.3] * 3}}, + ) + assert chosen == "b" + assert verdicts["a"].reason.startswith("slower on large") diff --git a/tests/conftest.py b/tests/conftest.py index d290c9200..e0573d0e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,11 @@ settings.register_profile("solprop", deadline=None) settings.load_profile("solprop") +# A machine calibration (solprop SEP 46) neither runs nor applies in the suite unless a +# test asks for one, so that a developer's store cannot change what a test measures. +# Set before the native package's import reads it; subprocesses inherit it. +os.environ.setdefault("SOLPROP_CALIBRATION", "off") + # Any driver test can select the installed backend, even without collecting # tests/backends. A present but broken wheel fails here; only an absent optional wheel # is omitted. diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh index 46e520d76..c5ffdc519 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh @@ -23,7 +23,7 @@ for (( instance=0; instance<2; instance++ )); do export OPENBLAS_NUM_THREADS=2 export MKL_NUM_THREADS=2 export VECLIB_MAXIMUM_THREADS=2 - export OMP_WAIT_POLICY=passive + export SOLPROP_NATIVE_WAIT_REQUEST=passive exec /python -m solprop.scan '/shared/scan/scan '"'"'quoted'"'"'.py' --queue '/shared/scan/point queue' --threads 2 --memory-limit 6442450944 --workers 1 --fresh --chunk "$(( (SLURM_ARRAY_TASK_ID - 1) * 2 + instance ))/6" ) & pids+=("$!") diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh index 17bb292df..4eecabc4f 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh @@ -23,7 +23,7 @@ for (( instance=0; instance<2; instance++ )); do export OPENBLAS_NUM_THREADS=2 export MKL_NUM_THREADS=2 export VECLIB_MAXIMUM_THREADS=2 - export OMP_WAIT_POLICY=passive + export SOLPROP_NATIVE_WAIT_REQUEST=passive exec /python -m solprop.scan '/shared/scan/scan '"'"'quoted'"'"'.py' --queue '/shared/scan/point queue' --threads 2 --memory-limit 6442450944 --workers 1 --fresh ) & pids+=("$!") diff --git a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh index 24f4bafee..3b953805c 100644 --- a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh +++ b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh @@ -19,7 +19,7 @@ for (( instance=0; instance<1; instance++ )); do export OPENBLAS_NUM_THREADS=2 export MKL_NUM_THREADS=2 export VECLIB_MAXIMUM_THREADS=2 - export OMP_WAIT_POLICY=passive + export SOLPROP_NATIVE_WAIT_REQUEST=passive exec /python -m solprop.scan /shared/scan/single.py --queue '/shared/scan/single queue' --threads 2 --memory-limit 4294967296 --workers 1 ) & pids+=("$!") diff --git a/tests/test_benchmark_ab.py b/tests/test_benchmark_ab.py index b01a05baf..d7e03fc55 100644 --- a/tests/test_benchmark_ab.py +++ b/tests/test_benchmark_ab.py @@ -222,3 +222,94 @@ def test_each_arm_runs_its_own_backend_at_that_backends_precision(): "precision": "single", } assert ab.arm_configuration(values, "native")["precision"] == "double" + + +def test_each_arm_takes_its_own_environment(tmp_path, monkeypatch): + """A calibrated arm against the built-in table (SEP 46): only the arm's own + variables differ, over the threading preset, and the plan records them.""" + seen = [] + + def launch(python, request, environment, *_args): + seen.append(environment.get("SOLPROP_CALIBRATION")) + return [], 0.0 + + monkeypatch.setattr(ab, "launch_worker", launch) + monkeypatch.setattr(ab, "guard", lambda *_args: {"quiet": True}) + out = tmp_path / "run" + cli.main( + [ + "ab", + "--case", + "smoke", + "--threads", + "1", + "--rounds", + "1", + "--baseline-python", + sys.executable, + "--baseline-env", + "SOLPROP_CALIBRATION=off", + "--candidate-env", + "SOLPROP_CALIBRATION=use", + "--calibrate", + "none", + "--out", + str(out), + ] + ) + assert sorted(seen) == ["off", "use"] + plan = read_record(out / "plan.json") + assert plan["environments"] == { + "baseline": {"SOLPROP_CALIBRATION": "off"}, + "candidate": {"SOLPROP_CALIBRATION": "use"}, + } + + +def test_an_arms_environment_takes_name_value_pairs(): + assert ab.arm_environment(["A=1", "B="]) == {"A": "1", "B": ""} + with pytest.raises(ab.SuiteError, match="NAME=VALUE"): + ab.arm_environment(["broken"]) + + +def test_each_build_is_calibrated_once_unless_off(tmp_path, monkeypatch): + """SEP 46 section 6.13: a series calibrates each build first, once per + interpreter, with its arm's variables; an arm held off calibrates nothing.""" + from benchmarks import runner # noqa: PLC0415 + + ran = [] + + class Completed: + returncode = 0 + stdout = "calibration (quick): published in 1.0 s\n" + stderr = "" + + def run(command, **kwargs): + ran.append((command, kwargs["env"].get("A"))) + return Completed() + + monkeypatch.setattr(runner.subprocess, "run", run) + monkeypatch.delenv("SOLPROP_CALIBRATION", raising=False) + found = runner.calibrate_builds( + {"baseline": "/py/a", "candidate": "/py/a"}, + "quick", + tmp_path, + {"baseline": {"A": "1"}}, + ) + assert [command for command, _ in ran] == [ + ["/py/a", "-m", "solprop", "calibrate", "--quick"] + ] + assert found["candidate"] == found["baseline"] + assert (tmp_path / "calibration" / "baseline.txt").exists() + held = runner.calibrate_builds( + {"baseline": "/py/b"}, + "quick", + tmp_path, + {"baseline": {"SOLPROP_CALIBRATION": "off"}}, + ) + assert held == {"baseline": {"level": "off"}} + assert runner.calibrate_builds({"baseline": "/py/c"}, "none", tmp_path) == {} + assert len(ran) == 1 + assert runner.worker_calibration({"SOLPROP_CALIBRATION": "OFF"}) == { + "SOLPROP_CALIBRATION": "off" + } + assert runner.worker_calibration({}) == {"SOLPROP_CALIBRATION": "use"} diff --git a/tests/test_benchmark_cases.py b/tests/test_benchmark_cases.py index c6087c660..0f3e5276c 100644 --- a/tests/test_benchmark_cases.py +++ b/tests/test_benchmark_cases.py @@ -273,3 +273,16 @@ def test_mlx_configurations_run_in_single_precision_and_refuse_interaction_adams assert all(row["configuration"]["fft_size"] == "pow2" for row in rows) assert any("exponential formulation" in reason for reason in skipped) assert any(reason.startswith("planar") for reason in skipped) + + +def test_a_node_budget_reaches_every_builder_s_settings(monkeypatch): + """SOLPROP_BENCH_NODE_BUDGET sets the sources' chunk for an A/B's arm (#361).""" + from benchmarks.builders import NODE_BUDGET # noqa: PLC0415 + + assert build(configuration("radial_filament_256")).settings.node_budget is None + monkeypatch.setenv(NODE_BUDGET, str(2**34)) + for name in ("radial_filament_256", "he_rdw_averaged"): + assert build(configuration(name)).settings.node_budget == 2**34 + monkeypatch.setenv(NODE_BUDGET, "lots") + with pytest.raises(SuiteError, match="positive number of bytes"): + build(configuration("radial_filament_256")) diff --git a/tests/test_package.py b/tests/test_package.py index f81968e20..e08c0ee95 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -124,7 +124,7 @@ def test_exception_hierarchy_roots_at_solprop_error(): continue root = ( exceptions.SolpropWarning - if name.endswith("Warning") + if name.endswith(("Warning", "Notice")) else exceptions.SolpropError ) assert issubclass(cls, root)