Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions benches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,21 @@ run's artifacts into `REPORT.md` and Bencher Metric Format JSON.
Cross-engine comparisons against other propagation libraries live in
[`../packages/bench-third-party`](../packages/bench-third-party), a standalone uv
project with its own lockfile.

## Fixed-model sizing

At the default `--hubbard-lower-atol 1e-4`, both nominal Hubbard size axes are
saturated -- `--hubbard-cutoff` is flat from 10, `--hubbard-num-sites` is flat
from 60 -- so `--hubbard-lower-atol` is the only axis that reaches 100M terms.
Measured at `cutoff=10`: `lower_atol` 1e-4 / 5e-5 / 2.5e-5 / 1.25e-5 gives
1,887,255 / 7,156,480 / 26,607,878 / 96,981,051 terms, i.e. terms scale
roughly as `lower_atol^-1.9`.

`--hubbard-observable-site` (default 46) is a lattice position, not a
constant: sweeping `--hubbard-num-sites` without scaling it as 46/60 of the
lattice slides the observable off-centre and changes the light cone.

`build_graph` extends the graph, so Hubbard's 29 Trotter steps retain 29
layer-sets and exceed 229 GiB at 23.9M terms, where `propagate` runs the same
model to 97M terms in well under 2 GiB. Size a graph-holding benchmark from a
graph measurement, never from a `propagate` measurement.
34 changes: 29 additions & 5 deletions benches/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ def _size() -> int:
return 1 if MPI is None else MPI.COMM_WORLD.Get_size()


def _nodes() -> tuple[int, int]:
"""Return (distinct hosts, ranks per host). Collective; serial returns ``(1, 1)``."""
if MPI is None or MPI.COMM_WORLD.Get_size() == 1:
return 1, 1
hosts = MPI.COMM_WORLD.allgather(socket.gethostname())
n = len(set(hosts))
return n, _size() // n


def _reduce_sum(comm: Any, value: int) -> int:
"""Sum ``value`` across ranks. Collective; a serial run returns ``value``."""
if comm is not None and comm.Get_size() > 1:
Expand Down Expand Up @@ -132,6 +141,7 @@ def _spread(comm: Any, value: int) -> dict[str, int]:
"meta": {}, # run configuration (ranks, threads, host, ...)
"params": {}, # resolved random-problem hyperparameters
"memhwm": {}, # node id -> summed peak RSS, whole test, setup() included
"memhwm_max": {}, # node id -> worst-rank peak RSS, whole test, setup() included
"opsize": {}, # picture / model / node id -> {"terms": n}
"memrest": {}, # picture / model -> resting RSS bytes
"membase": {}, # fixed model -> resting RSS bytes before the model is built
Expand Down Expand Up @@ -191,16 +201,18 @@ def _core_md5() -> str:
return "unavailable"


def _meta() -> dict[str, Any]:
def _meta(nodes: int, ranks_per_node: int) -> dict[str, Any]:
"""Return this run's configuration metadata for the report."""
try:
nanobind_backend_version = version("nanobind-backend")
except PackageNotFoundError:
nanobind_backend_version = "not installed"

return {
meta = {
"label": os.environ.get("monoprop_BENCH_LABEL", "?"), # noqa: SIM112
"ranks": _size(),
"nodes": nodes,
"ranks_per_node": ranks_per_node,
"monoprop_threads": os.environ.get("monoprop_NUM_THREADS", "default"), # noqa: SIM112
"cpu_count_logical": psutil.cpu_count(logical=True),
"cpu_count_physical": psutil.cpu_count(logical=False),
Expand All @@ -218,6 +230,12 @@ def _meta() -> dict[str, Any]:
# Filled by _record_placement: the threads exist only once a propagator does.
"pinning": {},
}
# No nanobind property exposes the engine's resolved partition count (see bindings/binder.h),
# so record the requested env var under its own name rather than claim it is the effective value.
partitions_env = os.environ.get("monoprop_PARTITIONS") # noqa: SIM112
if partitions_env is not None:
meta["partitions_env"] = partitions_env
return meta


def _params(config: pytest.Config) -> dict[str, Any]:
Expand All @@ -235,8 +253,9 @@ def pytest_configure(config: pytest.Config) -> None:
Every rank runs the whole session, so rank 0 alone prints, writes and records.
``trylast`` so the terminal reporter exists before non-root ranks unregister it.
"""
nodes, ranks_per_node = _nodes() # collective; every rank must call this
if _rank() == 0:
_RESULTS["meta"] = _meta()
_RESULTS["meta"] = _meta(nodes, ranks_per_node)
_RESULTS["params"] = _params(config)
return

Expand Down Expand Up @@ -450,17 +469,22 @@ def _do(propagator: Any) -> int:

@pytest.fixture(autouse=True)
def record_memory(request: pytest.FixtureRequest, bench_comm: Any) -> Iterator[None]:
"""Record ``memhwm``: peak RSS over the whole test, summed over ranks.
"""Record ``memhwm`` (summed peak RSS) and ``memhwm_max`` (the worst rank's peak RSS).

It spans ``setup``, so it predicts an OOM kill but is the wrong number for comparing
operations -- ``opmemdelta`` is that. The reduce is collective; only rank 0 records.
operations -- ``opmemdelta`` is that. The sum alone has inverted per-rank readings here
before, so ``memhwm_max`` is recorded alongside it, never in place of it. Both reduces
are collective; only rank 0 records.
"""
with HighWaterMark() as window:
yield
key = request.node.nodeid.split("/")[-1]
hwm = _reduce_sum(bench_comm, window.peak_bytes)
hwm_max = _reduce_max(bench_comm, window.peak_bytes)
if hwm:
_record("memhwm", key, hwm)
if hwm_max:
_record("memhwm_max", key, hwm_max)


@pytest.fixture(scope="session", params=["heisenberg", "schrodinger"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ def _config_table(labels: list[str], results: dict[str, dict]) -> list[str]:
"nanobind",
"Backend",
"Ranks",
"Nodes",
"Ranks/node",
"Partitions (requested)",
"monoprop threads",
"CPUs (logical/physical)",
"Host",
Expand All @@ -215,6 +218,9 @@ def _config_table(labels: list[str], results: dict[str, dict]) -> list[str]:
str(metas[label].get("nanobind_version", "—")),
str(metas[label].get("nanobind_backend_version", "—")),
str(metas[label].get("ranks", "—")),
str(metas[label].get("nodes", "—")),
str(metas[label].get("ranks_per_node", "—")),
str(metas[label].get("partitions_env", "—")),
str(metas[label].get("monoprop_threads", "default")),
_fmt_cpus(metas[label]),
str(metas[label].get("hostname", "—")),
Expand Down Expand Up @@ -266,16 +272,18 @@ def build_report(results_dir: Path) -> str:
def sec(name: str) -> dict[str, dict]:
return {lbl: results.get(lbl, {}).get(name, {}) for lbl in labels}

params, opsize, memrest, memory = (
params, opsize, memrest, memory, memory_max = (
sec("params"),
sec("opsize"),
sec("memrest"),
sec("memhwm"),
sec("memhwm_max"),
)

all_ops = sorted(
{op for table in timings.values() for op in table}
| {op for table in memory.values() for op in table}
| {op for table in memory_max.values() for op in table}
)
if not labels or not all_ops:
return (
Expand Down Expand Up @@ -304,14 +312,23 @@ def ops_section(name: str, picture: str) -> list[str]:
level=3,
),
*_section(
"Memory (peak RSS)",
"Memory (peak RSS, summed across ranks)",
"",
"Operation",
ops,
labels,
lambda lbl, op: _fmt_mem(memory.get(lbl, {}).get(op)),
level=3,
),
*_section(
"Memory (peak RSS, max across ranks)",
"",
"Operation",
ops,
labels,
lambda lbl, op: _fmt_mem(memory_max.get(lbl, {}).get(op)),
level=3,
),
]

lines = [
Expand All @@ -320,8 +337,8 @@ def ops_section(name: str, picture: str) -> list[str]:
f"Run labels: **{', '.join(labels)}**. Times are the mean over rounds; "
"memory is the kernel's exact peak resident footprint (`VmHWM`) during each "
"operation, measured from a window reset and settled per operation. Under MPI "
"it is summed across ranks, so ranks peaking at different moments are counted "
"together: an upper bound on the job total.",
"the summed figure counts ranks peaking at different moments together (an "
"upper bound on the job total); the max figure is the single worst rank.",
"",
*_config_table(labels, results),
*_section(
Expand Down
27 changes: 26 additions & 1 deletion packages/monoprop-bench-tools/tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ def test_build_report_includes_runtime_provenance(tmp_path: Path) -> None:
assert "| np1 | 3.13.2 | 3.2.0 | 2.4.0 |" in md


def test_build_report_includes_node_placement(tmp_path: Path) -> None:
_write_timings(tmp_path)
_write_results(
tmp_path,
meta={"nodes": 4, "ranks_per_node": 8, "partitions_env": "16"},
)
md = _collapse(report.build_report(tmp_path))

assert "| Nodes | Ranks/node | Partitions (requested) |" in md
assert "| 4 | 8 | 16 |" in md


def test_build_report_omits_node_placement_when_absent(tmp_path: Path) -> None:
_write_timings(tmp_path)
_write_results(tmp_path, meta={"ranks": 8})
md = _collapse(report.build_report(tmp_path))

assert "| 8 | — | — | — | default |" in md


def test_fmt_config_formats_floats_compactly() -> None:
assert report._fmt_config(1e-5) == "1e-05"
assert report._fmt_config(1.0) == "1"
Expand Down Expand Up @@ -196,12 +216,17 @@ def test_build_report_includes_memory(tmp_path: Path) -> None:
"bench_random.py::test_random_energy[heisenberg]": 52428800,
"bench_random.py::test_random_energy[schrodinger]": 104857600,
},
memhwm_max={
"bench_random.py::test_random_energy[heisenberg]": 41943040,
},
)
md = _collapse(report.build_report(tmp_path))

assert "Memory (peak RSS)" in md
assert "Memory (peak RSS, summed across ranks)" in md
assert "Memory (peak RSS, max across ranks)" in md
assert "50.00 MiB" in md
assert "100.00 MiB" in md
assert "40.00 MiB" in md


def test_build_report_includes_resting(tmp_path: Path) -> None:
Expand Down
Loading