analyze: estimate per-job HTCondor walltime from chunk row count
CI / Lint (ruff check) (push) Successful in 1m0s
CI / Format (ruff format) (push) Failing after 1m5s
CI / Type check (ty) (push) Successful in 1m13s
CI / Tests (push) Successful in 1m45s
CI / Lint (ruff check) (pull_request) Successful in 1m10s
CI / Format (ruff format) (pull_request) Failing after 1m11s
CI / Type check (ty) (pull_request) Successful in 1m7s
CI / Tests (pull_request) Successful in 1m42s
CI / Bump version, build & publish wheel (push) Has been skipped
CI / Bump version, build & publish wheel (pull_request) Has been skipped
CI / Lint (ruff check) (push) Successful in 1m0s
CI / Format (ruff format) (push) Failing after 1m5s
CI / Type check (ty) (push) Successful in 1m13s
CI / Tests (push) Successful in 1m45s
CI / Lint (ruff check) (pull_request) Successful in 1m10s
CI / Format (ruff format) (pull_request) Failing after 1m11s
CI / Type check (ty) (pull_request) Successful in 1m7s
CI / Tests (pull_request) Successful in 1m42s
CI / Bump version, build & publish wheel (push) Has been skipped
CI / Bump version, build & publish wheel (pull_request) Has been skipped
Each condor job's +RequestWalltime used to be one flat 3600s default for every (plot, chunk), regardless of how much data it actually streams over. `prep` now records each chunk's rollout+reference row count, and `giant/analysis/runtime_estimate.py` turns that into a per-job estimate: a per-spec (intercept, seconds/row) cost model fit by `scripts/profile_analysis_costs.py` against synthetic mock data on this machine, plus a fixed overhead placeholder (docker/uv/shared-fs startup — unmeasurable here, no /ceph access) and a single RUNTIME_SAFETY_MARGIN multiplier. jobs.txt gains a walltime column and the submit description references it via $(walltime) instead of a constant.
This commit is contained in:
@@ -25,6 +25,7 @@ from giant.analysis.condor import (
|
|||||||
)
|
)
|
||||||
from giant.analysis.context import Context, build_context
|
from giant.analysis.context import Context, build_context
|
||||||
from giant.analysis.reduced import Partial, Reduced
|
from giant.analysis.reduced import Partial, Reduced
|
||||||
|
from giant.analysis.runtime_estimate import RUNTIME_SAFETY_MARGIN, estimate_runtime_s
|
||||||
from giant.analysis.sources import Side
|
from giant.analysis.sources import Side
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -46,4 +47,6 @@ __all__ = [
|
|||||||
"Partial",
|
"Partial",
|
||||||
"Reduced",
|
"Reduced",
|
||||||
"Side",
|
"Side",
|
||||||
|
"RUNTIME_SAFETY_MARGIN",
|
||||||
|
"estimate_runtime_s",
|
||||||
]
|
]
|
||||||
|
|||||||
+64
-13
@@ -42,14 +42,17 @@ HTCondor file transfer of the multi-GB inputs.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from giant.analysis.catalog import Bundle, catalog_ids, get_spec
|
from giant.analysis.catalog import Bundle, catalog_ids, get_spec
|
||||||
from giant.analysis.context import Context, build_context
|
from giant.analysis.context import Context, build_context
|
||||||
from giant.analysis.reduced import Partial
|
from giant.analysis.reduced import Partial
|
||||||
|
from giant.analysis.runtime_estimate import estimate_runtime_s
|
||||||
|
from giant.analysis.sources import Side, open_side
|
||||||
|
|
||||||
# Keys copied verbatim from a rollout YAML into each plot's gallery metadata.
|
# Keys copied verbatim from a rollout YAML into each plot's gallery metadata.
|
||||||
_PLOT_META_KEYS = (
|
_PLOT_META_KEYS = (
|
||||||
@@ -113,6 +116,11 @@ class RunMeta:
|
|||||||
title: str
|
title: str
|
||||||
plot_meta: dict
|
plot_meta: dict
|
||||||
n_chunks: int = 1
|
n_chunks: int = 1
|
||||||
|
# rollout+reference row count of each event_id-disjoint chunk, and the
|
||||||
|
# dataset total — inputs to `runtime_estimate.estimate_runtime_s`. Empty/0
|
||||||
|
# on run directories written before this field existed.
|
||||||
|
rows_per_chunk: list[int] = field(default_factory=list)
|
||||||
|
total_rows: int = 0
|
||||||
|
|
||||||
def save(self, path: str | Path) -> None:
|
def save(self, path: str | Path) -> None:
|
||||||
Path(path).write_text(json.dumps(self.__dict__, indent=2))
|
Path(path).write_text(json.dumps(self.__dict__, indent=2))
|
||||||
@@ -122,6 +130,30 @@ class RunMeta:
|
|||||||
return cls(**json.loads(Path(path).read_text()))
|
return cls(**json.loads(Path(path).read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_per_chunk(rollout: str | Path, reference: str | Path, n_chunks: int) -> list[int]:
|
||||||
|
"""Rollout+reference row count of each ``event_id % n_chunks`` chunk.
|
||||||
|
|
||||||
|
One cheap streaming ``group_by`` per side (just the ``event_id`` column) —
|
||||||
|
the sizing input every job's estimated walltime
|
||||||
|
(``runtime_estimate.estimate_runtime_s``) is computed from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def counts(lf: pl.LazyFrame) -> pl.DataFrame:
|
||||||
|
return (
|
||||||
|
lf.select((pl.col("event_id") % n_chunks).alias("_c"))
|
||||||
|
.group_by("_c")
|
||||||
|
.agg(pl.len().alias("n"))
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
out = [0] * n_chunks
|
||||||
|
for lf in (open_side(rollout, Side.rollout), open_side(reference, Side.reference)):
|
||||||
|
df = counts(lf)
|
||||||
|
for c, n in zip(df["_c"].to_list(), df["n"].to_list()):
|
||||||
|
out[c] += n
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def prep(
|
def prep(
|
||||||
rollout_yaml: str | Path,
|
rollout_yaml: str | Path,
|
||||||
run_dir: str | Path | None = None,
|
run_dir: str | Path | None = None,
|
||||||
@@ -144,6 +176,8 @@ def prep(
|
|||||||
ctx = build_context(rollout, reference, **ctx_kwargs)
|
ctx = build_context(rollout, reference, **ctx_kwargs)
|
||||||
ctx.save(run_path / "shared.json")
|
ctx.save(run_path / "shared.json")
|
||||||
|
|
||||||
|
rows_per_chunk = _rows_per_chunk(rollout, reference, n_chunks)
|
||||||
|
|
||||||
ckpt = Path(y.get("checkpoint", "")).name or "rollout"
|
ckpt = Path(y.get("checkpoint", "")).name or "rollout"
|
||||||
RunMeta(
|
RunMeta(
|
||||||
rollout=str(rollout),
|
rollout=str(rollout),
|
||||||
@@ -152,6 +186,8 @@ def prep(
|
|||||||
title=f"GIANT rollout analysis — {ckpt}",
|
title=f"GIANT rollout analysis — {ckpt}",
|
||||||
plot_meta=_plot_meta(y),
|
plot_meta=_plot_meta(y),
|
||||||
n_chunks=n_chunks,
|
n_chunks=n_chunks,
|
||||||
|
rows_per_chunk=rows_per_chunk,
|
||||||
|
total_rows=sum(rows_per_chunk),
|
||||||
).save(run_path / "run_meta.json")
|
).save(run_path / "run_meta.json")
|
||||||
return run_path
|
return run_path
|
||||||
|
|
||||||
@@ -273,7 +309,6 @@ class SubmitConfig:
|
|||||||
docker_image: str = "mschnepf/slc7-condocker"
|
docker_image: str = "mschnepf/slc7-condocker"
|
||||||
request_memory_mb: int = 4096
|
request_memory_mb: int = 4096
|
||||||
request_cpus: int = 1
|
request_cpus: int = 1
|
||||||
request_walltime_s: int = 3600
|
|
||||||
remote: bool = False # +RemoteJob (grid I/O) vs ProvidesETPResources (local files)
|
remote: bool = False # +RemoteJob (grid I/O) vs ProvidesETPResources (local files)
|
||||||
n_chunks: int = 1 # per-plot data chunks; ignored for chunkable=False specs
|
n_chunks: int = 1 # per-plot data chunks; ignored for chunkable=False specs
|
||||||
|
|
||||||
@@ -300,25 +335,45 @@ def _submit_description(cfg: SubmitConfig, wrapper: Path, jobs_file: Path) -> st
|
|||||||
"when_to_transfer_output = ON_EXIT\n"
|
"when_to_transfer_output = ON_EXIT\n"
|
||||||
f"request_memory = {cfg.request_memory_mb}\n"
|
f"request_memory = {cfg.request_memory_mb}\n"
|
||||||
f"request_cpus = {cfg.request_cpus}\n"
|
f"request_cpus = {cfg.request_cpus}\n"
|
||||||
f"+RequestWalltime = {cfg.request_walltime_s}\n"
|
"+RequestWalltime = $(walltime)\n"
|
||||||
f"accounting_group = {cfg.accounting_group}\n"
|
f"accounting_group = {cfg.accounting_group}\n"
|
||||||
f"{reqs_attrs}"
|
f"{reqs_attrs}"
|
||||||
f"output = {cfg.run_dir}/logs/$(plotid)__$(chunk).out\n"
|
f"output = {cfg.run_dir}/logs/$(plotid)__$(chunk).out\n"
|
||||||
f"error = {cfg.run_dir}/logs/$(plotid)__$(chunk).err\n"
|
f"error = {cfg.run_dir}/logs/$(plotid)__$(chunk).err\n"
|
||||||
f"log = {cfg.run_dir}/logs/condor.log\n"
|
f"log = {cfg.run_dir}/logs/condor.log\n"
|
||||||
f"queue plotid,chunk from {jobs_file}\n"
|
f"queue plotid,chunk,walltime from {jobs_file}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_walltimes(run_dir: Path, ids: list[str], n_chunks: int) -> list[tuple[str, int, int]]:
|
||||||
|
"""``(spec_id, chunk, walltime_s)`` for every job, sized from ``run_meta.json``.
|
||||||
|
|
||||||
|
Row counts come from ``prep``'s ``RunMeta.rows_per_chunk``/``total_rows``;
|
||||||
|
``chunkable=False`` specs (router diagnostics) always use the dataset
|
||||||
|
total since they run as a single job regardless of ``n_chunks``.
|
||||||
|
"""
|
||||||
|
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||||
|
jobs: list[tuple[str, int, int]] = []
|
||||||
|
for spec_id in ids:
|
||||||
|
chunkable = get_spec(spec_id).chunkable
|
||||||
|
chunks = range(n_chunks) if chunkable else [0]
|
||||||
|
for chunk in chunks:
|
||||||
|
n_rows = meta.rows_per_chunk[chunk] if chunkable else meta.total_rows
|
||||||
|
jobs.append((spec_id, chunk, estimate_runtime_s(spec_id, n_rows)))
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def write_submit(cfg: SubmitConfig, ids: list[str] | None = None) -> Path:
|
def write_submit(cfg: SubmitConfig, ids: list[str] | None = None) -> Path:
|
||||||
"""Write the wrapper script, (plot, chunk) job list, and HTCondor submit
|
"""Write the wrapper script, (plot, chunk) job list, and HTCondor submit
|
||||||
description.
|
description.
|
||||||
|
|
||||||
Each catalog id gets ``cfg.n_chunks`` jobs, except ``chunkable=False``
|
Each catalog id gets ``cfg.n_chunks`` jobs, except ``chunkable=False``
|
||||||
specs (the router diagnostics), which always get exactly one regardless of
|
specs (the router diagnostics), which always get exactly one regardless of
|
||||||
``cfg.n_chunks``. Returns the submit description path
|
``cfg.n_chunks``. Every job's ``+RequestWalltime`` is estimated from its
|
||||||
(``<run_dir>/analyze.sub``). Does not submit — call ``condor_submit`` on
|
chunk's row count (``runtime_estimate.estimate_runtime_s``, requires
|
||||||
the returned file.
|
``run_meta.json`` from ``prep`` to already carry ``rows_per_chunk``).
|
||||||
|
Returns the submit description path (``<run_dir>/analyze.sub``). Does not
|
||||||
|
submit — call ``condor_submit`` on the returned file.
|
||||||
"""
|
"""
|
||||||
ids = ids or catalog_ids()
|
ids = ids or catalog_ids()
|
||||||
run_dir = cfg.run_dir
|
run_dir = cfg.run_dir
|
||||||
@@ -330,13 +385,9 @@ def write_submit(cfg: SubmitConfig, ids: list[str] | None = None) -> Path:
|
|||||||
wrapper.write_text(_WRAPPER.format(repo_dir=cfg.repo_dir, run_dir=run_dir))
|
wrapper.write_text(_WRAPPER.format(repo_dir=cfg.repo_dir, run_dir=run_dir))
|
||||||
wrapper.chmod(0o755)
|
wrapper.chmod(0o755)
|
||||||
|
|
||||||
jobs = [
|
jobs = _job_walltimes(run_dir, ids, cfg.n_chunks)
|
||||||
(spec_id, chunk)
|
|
||||||
for spec_id in ids
|
|
||||||
for chunk in range(cfg.n_chunks if get_spec(spec_id).chunkable else 1)
|
|
||||||
]
|
|
||||||
jobs_file = run_dir / "jobs.txt"
|
jobs_file = run_dir / "jobs.txt"
|
||||||
jobs_file.write_text("\n".join(f"{i},{k}" for i, k in jobs) + "\n")
|
jobs_file.write_text("\n".join(f"{i},{k},{w}" for i, k, w in jobs) + "\n")
|
||||||
|
|
||||||
sub = run_dir / "analyze.sub"
|
sub = run_dir / "analyze.sub"
|
||||||
sub.write_text(_submit_description(cfg, wrapper, jobs_file))
|
sub.write_text(_submit_description(cfg, wrapper, jobs_file))
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Per-(plot, chunk) HTCondor walltime estimates for `giant analyze submit`.
|
||||||
|
|
||||||
|
Each catalog spec's compute cost is close to linear in the number of input
|
||||||
|
rows a `compute-one` job streams over — every spec is one (or a couple of)
|
||||||
|
streaming `group_by` pass(es) over the chunk (see `catalog.py`/`reduce.py`).
|
||||||
|
`_COST_MODEL` below is ``spec_id -> (intercept_s, seconds_per_row)``, fit by
|
||||||
|
least squares against wall-clock timings of `compute_reduced` on synthetic
|
||||||
|
mock data of increasing size, run on a local dev machine (see
|
||||||
|
`scripts/profile_analysis_costs.py` — rerun it and paste the new numbers in
|
||||||
|
here if the catalog changes or this needs recalibrating). ``n_rows`` is the
|
||||||
|
combined rollout+reference row count of the job's input: the chunk's row
|
||||||
|
count for `chunkable=True` specs, the whole dataset's for the three
|
||||||
|
`chunkable=False` router specs (they always run as a single job regardless of
|
||||||
|
chunk count).
|
||||||
|
|
||||||
|
The fitted numbers only capture *local, in-memory compute* — they don't (and
|
||||||
|
from a laptop with no `/ceph` access, can't) capture the real condor job's
|
||||||
|
docker pull, `uv run` cold start, or shared-filesystem read latency, which in
|
||||||
|
practice likely dominate total wall time for anything but a huge single-chunk
|
||||||
|
job. `_FIXED_OVERHEAD_S` is a deliberately generous placeholder for all of
|
||||||
|
that combined; recalibrate it from real `condor_q`/log timings once some are
|
||||||
|
available, rather than trusting it as measured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Multiplicative pad applied to every job's estimated walltime. The one knob
|
||||||
|
# this feature was asked to expose.
|
||||||
|
RUNTIME_SAFETY_MARGIN = 1.00
|
||||||
|
|
||||||
|
# Docker image pull + `uv run` startup + shared (/ceph, ETP) filesystem read
|
||||||
|
# latency — not measurable on a machine with no /ceph access, so this is a
|
||||||
|
# conservative placeholder rather than a fit. Recalibrate from real job logs.
|
||||||
|
_FIXED_OVERHEAD_S = 600.0
|
||||||
|
|
||||||
|
# Router diagnostics need a live torch checkpoint to do any real work; this
|
||||||
|
# machine has none, so their cost (torch.load + a bounded inference pass over
|
||||||
|
# <= 200k subsampled rows, independent of chunk size) couldn't be profiled
|
||||||
|
# either. Fixed budget, on top of _FIXED_OVERHEAD_S, instead of a row-based fit.
|
||||||
|
_ROUTER_FIXED_S = 300.0
|
||||||
|
_ROUTER_IDS = frozenset({"router_gating", "router_share_by_pdg", "router_share_by_process"})
|
||||||
|
|
||||||
|
# Conservative fallback for any catalog id not in _COST_MODEL (e.g. a plot
|
||||||
|
# added after the last profiling run) — the most expensive fitted (intercept,
|
||||||
|
# seconds/row) pair observed, rounded up.
|
||||||
|
_DEFAULT_COST = (0.02, 2.0e-6)
|
||||||
|
|
||||||
|
# spec_id -> (intercept_s, seconds_per_row), fit on this machine 2026-07-27
|
||||||
|
# via `scripts/profile_analysis_costs.py` against SIDE_ROW_COUNTS up to 2M
|
||||||
|
# rows/side (4M combined).
|
||||||
|
_COST_MODEL: dict[str, tuple[float, float]] = {
|
||||||
|
"marginal_step_length": (0.003581, 0.000000042),
|
||||||
|
"marginal_step_length_by_energy": (0.010336, 0.000000081),
|
||||||
|
"marginal_step_length_by_pdg": (0.007231, 0.000000042),
|
||||||
|
"marginal_step_length_by_material": (0.004662, 0.000000048),
|
||||||
|
"marginal_edep": (0.003996, 0.000000042),
|
||||||
|
"marginal_edep_by_energy": (0.007977, 0.000000085),
|
||||||
|
"marginal_edep_by_pdg": (0.003535, 0.000000042),
|
||||||
|
"marginal_edep_by_material": (0.003893, 0.000000047),
|
||||||
|
"marginal_delta_e": (0.001380, 0.000000053),
|
||||||
|
"marginal_delta_e_by_energy": (0.006543, 0.000000093),
|
||||||
|
"marginal_delta_e_by_pdg": (0.001761, 0.000000054),
|
||||||
|
"marginal_delta_e_by_material": (0.000000, 0.000000168),
|
||||||
|
"marginal_post_E": (0.000000, 0.000000146),
|
||||||
|
"marginal_post_E_by_energy": (0.000000, 0.000000509),
|
||||||
|
"marginal_post_E_by_pdg": (0.000000, 0.000000149),
|
||||||
|
"marginal_post_E_by_material": (0.000000, 0.000000168),
|
||||||
|
"marginal_cos_scatter": (0.000000, 0.000000249),
|
||||||
|
"marginal_cos_scatter_by_energy": (0.000000, 0.000000553),
|
||||||
|
"marginal_cos_scatter_by_pdg": (0.000000, 0.000000270),
|
||||||
|
"marginal_cos_scatter_by_material": (0.000000, 0.000000260),
|
||||||
|
"event_total_edep": (0.000000, 0.000000512),
|
||||||
|
"event_total_edep_by_energy": (0.000000, 0.000000241),
|
||||||
|
"event_mean_length": (0.000000, 0.000000090),
|
||||||
|
"event_n_steps": (0.002021, 0.000000066),
|
||||||
|
"shower_longitudinal": (0.000000, 0.000001804),
|
||||||
|
"shower_transverse": (0.000000, 0.000001731),
|
||||||
|
"species_edep_share": (0.007505, 0.000000017),
|
||||||
|
"leakage_fraction": (0.003798, 0.000000026),
|
||||||
|
"sec_count_per_event": (0.006551, 0.000000054),
|
||||||
|
"sec_count_per_species": (0.006708, 0.000000042),
|
||||||
|
"sec_energy": (0.006856, 0.000000047),
|
||||||
|
"sec_cos_angle": (0.001780, 0.000000234),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_runtime_s(spec_id: str, n_rows: int) -> int:
|
||||||
|
"""Estimated `+RequestWalltime` (seconds) for one (plot, chunk) job.
|
||||||
|
|
||||||
|
``n_rows`` is the rollout+reference row count of that job's input slice.
|
||||||
|
Includes `_FIXED_OVERHEAD_S`/`_ROUTER_FIXED_S` and `RUNTIME_SAFETY_MARGIN`
|
||||||
|
— callers should pass this straight through to the submit description.
|
||||||
|
"""
|
||||||
|
if spec_id in _ROUTER_IDS:
|
||||||
|
compute_s = _ROUTER_FIXED_S
|
||||||
|
else:
|
||||||
|
intercept, per_row = _COST_MODEL.get(spec_id, _DEFAULT_COST)
|
||||||
|
compute_s = intercept + per_row * n_rows
|
||||||
|
total = _FIXED_OVERHEAD_S + compute_s
|
||||||
|
return math.ceil(total * (1 + RUNTIME_SAFETY_MARGIN))
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Benchmark `giant analyze compute-one`'s per-job cost against synthetic data.
|
||||||
|
|
||||||
|
Generates mock rollout+reference parquet files at a few row counts, times
|
||||||
|
`compute_reduced` for every chunkable catalog spec at each size (a single
|
||||||
|
chunk covering the whole mock file), fits a straight line (intercept, seconds
|
||||||
|
per row) through the timings, and prints the result as a Python dict literal
|
||||||
|
ready to paste into `giant/analysis/runtime_estimate.py::_COST_MODEL`.
|
||||||
|
|
||||||
|
The three `chunkable=False` router specs (`router_gating`,
|
||||||
|
`router_share_by_pdg`, `router_share_by_process`) need a live MoE checkpoint
|
||||||
|
to do any real work; without one (this machine has no `/ceph` access, so no
|
||||||
|
real checkpoint) they short-circuit almost instantly and are excluded here —
|
||||||
|
see `runtime_estimate.py`'s `_ROUTER_FIXED_S` for how those are handled
|
||||||
|
instead.
|
||||||
|
|
||||||
|
Usage: ``uv run python scripts/profile_analysis_costs.py``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from giant.analysis.catalog import catalog_ids, get_spec
|
||||||
|
from giant.analysis.condor import compute_reduced
|
||||||
|
from giant.analysis.context import build_context
|
||||||
|
|
||||||
|
# Row counts (per side) to benchmark at. Kept in local memory/CPU range so the
|
||||||
|
# whole sweep finishes in about a minute; the fit is linear so it extrapolates
|
||||||
|
# fine to real multi-GB rollouts.
|
||||||
|
SIDE_ROW_COUNTS = [20_000, 100_000, 500_000, 2_000_000]
|
||||||
|
|
||||||
|
_MATERIALS = ["G4_PbWO4", "G4_Pb", "G4_lAr", "G4_Si"]
|
||||||
|
_PDGS = [11, -11, 22, 2112, 2212, 211, -211, 13]
|
||||||
|
_ROUTER_IDS = {"router_gating", "router_share_by_pdg", "router_share_by_process"}
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_vectors(n: int, rng: np.random.Generator) -> np.ndarray:
|
||||||
|
v = rng.normal(size=(n, 3))
|
||||||
|
return v / np.linalg.norm(v, axis=1, keepdims=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ragged_lists(k: np.ndarray, rng: np.random.Generator, lo: float, hi: float):
|
||||||
|
total = int(k.sum())
|
||||||
|
flat = rng.uniform(lo, hi, size=total)
|
||||||
|
idx = np.cumsum(k)[:-1]
|
||||||
|
return [arr.tolist() for arr in np.split(flat, idx)]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_rollout(n: int, n_events: int, seed: int) -> pl.DataFrame:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
event_id = rng.integers(0, n_events, size=n)
|
||||||
|
is_secondary = rng.random(n) < 0.15 # generation>0, step_no==0 birth rows
|
||||||
|
is_synthetic = rng.random(n) < 0.05 # bookkeeping termination rows
|
||||||
|
|
||||||
|
pre_E = rng.lognormal(mean=3.0, sigma=1.5, size=n)
|
||||||
|
edep = rng.uniform(0, 1, size=n) * pre_E * 0.3
|
||||||
|
post_E = np.clip(pre_E - edep, 0.0, None)
|
||||||
|
pre_dir = _unit_vectors(n, rng)
|
||||||
|
post_dir = _unit_vectors(n, rng)
|
||||||
|
pos = rng.uniform(-50, 300, size=(n, 3))
|
||||||
|
step_length = rng.uniform(0.1, 10.0, size=n)
|
||||||
|
post_pos = pos + pre_dir * step_length[:, None]
|
||||||
|
|
||||||
|
reasons = np.where(
|
||||||
|
is_synthetic,
|
||||||
|
rng.choice(
|
||||||
|
["escaped", "energy_cutoff", "max_steps", "unknown_pdg"], size=n
|
||||||
|
),
|
||||||
|
"natural_end",
|
||||||
|
)
|
||||||
|
|
||||||
|
return pl.DataFrame(
|
||||||
|
{
|
||||||
|
"event_id": event_id,
|
||||||
|
"track_id": rng.integers(0, 5, size=n),
|
||||||
|
"parent_id": np.where(is_secondary, 0, -1),
|
||||||
|
"generation": is_secondary.astype(np.int64),
|
||||||
|
"step_no": np.where(is_secondary, 0, rng.integers(0, 20, size=n)),
|
||||||
|
"pdg": rng.choice(_PDGS, size=n),
|
||||||
|
"pre_x": pos[:, 0],
|
||||||
|
"pre_y": pos[:, 1],
|
||||||
|
"pre_z": pos[:, 2],
|
||||||
|
"pre_E": pre_E,
|
||||||
|
"pre_dx": pre_dir[:, 0],
|
||||||
|
"pre_dy": pre_dir[:, 1],
|
||||||
|
"pre_dz": pre_dir[:, 2],
|
||||||
|
"post_x": post_pos[:, 0],
|
||||||
|
"post_y": post_pos[:, 1],
|
||||||
|
"post_z": post_pos[:, 2],
|
||||||
|
"post_E": post_E,
|
||||||
|
"post_dx": post_dir[:, 0],
|
||||||
|
"post_dy": post_dir[:, 1],
|
||||||
|
"post_dz": post_dir[:, 2],
|
||||||
|
"edep": np.where(is_synthetic, np.where(reasons == "escaped", 0.0, pre_E), edep),
|
||||||
|
"step_length": np.where(is_synthetic, 0.0, step_length),
|
||||||
|
"material": rng.choice(_MATERIALS, size=n),
|
||||||
|
"layer_id": rng.integers(0, 30, size=n),
|
||||||
|
"n_sec_pred": rng.integers(0, 4, size=n),
|
||||||
|
"termination_reason": reasons,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_reference(n: int, n_events: int, seed: int) -> pl.DataFrame:
|
||||||
|
rng = np.random.default_rng(seed + 1)
|
||||||
|
event_id = rng.integers(0, n_events, size=n)
|
||||||
|
pre_E = rng.lognormal(mean=3.0, sigma=1.5, size=n)
|
||||||
|
edep = rng.uniform(0, 1, size=n) * pre_E * 0.3
|
||||||
|
post_E = np.clip(pre_E - edep, 0.0, None)
|
||||||
|
pre_dir = _unit_vectors(n, rng)
|
||||||
|
post_dir = _unit_vectors(n, rng)
|
||||||
|
pos = rng.uniform(-50, 300, size=(n, 3))
|
||||||
|
step_length = rng.uniform(0.1, 10.0, size=n)
|
||||||
|
post_pos = pos + pre_dir * step_length[:, None]
|
||||||
|
|
||||||
|
k = rng.poisson(0.3, size=n).clip(max=5).astype(np.int64)
|
||||||
|
sec_pdg = _ragged_lists(k, rng, 0, 1) # placeholder, overwritten below
|
||||||
|
sec_E = _ragged_lists(k, rng, 0.1, 50.0)
|
||||||
|
sec_dx = _ragged_lists(k, rng, -1.0, 1.0)
|
||||||
|
sec_dy = _ragged_lists(k, rng, -1.0, 1.0)
|
||||||
|
sec_dz = _ragged_lists(k, rng, -1.0, 1.0)
|
||||||
|
total = int(k.sum())
|
||||||
|
flat_pdg = rng.choice(_PDGS, size=total).tolist()
|
||||||
|
idx = np.cumsum(k)[:-1]
|
||||||
|
sec_pdg = [list(x) for x in np.split(np.array(flat_pdg), idx)]
|
||||||
|
|
||||||
|
return pl.DataFrame(
|
||||||
|
{
|
||||||
|
"event_id": event_id,
|
||||||
|
"track_id": rng.integers(0, 5, size=n),
|
||||||
|
"step_no": rng.integers(0, 20, size=n),
|
||||||
|
"pdg": rng.choice(_PDGS, size=n),
|
||||||
|
"pre_x": pos[:, 0],
|
||||||
|
"pre_y": pos[:, 1],
|
||||||
|
"pre_z": pos[:, 2],
|
||||||
|
"pre_E": pre_E,
|
||||||
|
"pre_dx": pre_dir[:, 0],
|
||||||
|
"pre_dy": pre_dir[:, 1],
|
||||||
|
"pre_dz": pre_dir[:, 2],
|
||||||
|
"post_x": post_pos[:, 0],
|
||||||
|
"post_y": post_pos[:, 1],
|
||||||
|
"post_z": post_pos[:, 2],
|
||||||
|
"post_E": post_E,
|
||||||
|
"post_dx": post_dir[:, 0],
|
||||||
|
"post_dy": post_dir[:, 1],
|
||||||
|
"post_dz": post_dir[:, 2],
|
||||||
|
"edep": edep,
|
||||||
|
"step_length": step_length,
|
||||||
|
"material": rng.choice(_MATERIALS, size=n),
|
||||||
|
"layer_id": rng.integers(0, 30, size=n),
|
||||||
|
"process": rng.choice(["compt", "phot", "eBrem", "eIoni", "conv"], size=n),
|
||||||
|
"sec_E_list": sec_E,
|
||||||
|
"sec_pdg_list": sec_pdg,
|
||||||
|
"sec_dx_list": sec_dx,
|
||||||
|
"sec_dy_list": sec_dy,
|
||||||
|
"sec_dz_list": sec_dz,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _time(spec_id: str, rollout: Path, reference: Path, shared: Path, out: Path) -> float:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
compute_reduced(
|
||||||
|
spec_id, rollout, reference, shared, out, checkpoint=None,
|
||||||
|
chunk_index=0, n_chunks=1,
|
||||||
|
)
|
||||||
|
return time.perf_counter() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ids = [i for i in catalog_ids() if get_spec(i).chunkable]
|
||||||
|
timings: dict[str, list[tuple[int, float]]] = {i: [] for i in ids}
|
||||||
|
|
||||||
|
with TemporaryDirectory(prefix="giant-profile-") as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
for n_side in SIDE_ROW_COUNTS:
|
||||||
|
n_events = max(n_side // 20, 10)
|
||||||
|
rollout = tmp_path / f"rollout_{n_side}.parquet"
|
||||||
|
reference = tmp_path / f"reference_{n_side}.parquet"
|
||||||
|
_make_rollout(n_side, n_events, seed=0).write_parquet(rollout)
|
||||||
|
_make_reference(n_side, n_events, seed=0).write_parquet(reference)
|
||||||
|
|
||||||
|
shared = tmp_path / f"shared_{n_side}.json"
|
||||||
|
ctx = build_context(
|
||||||
|
rollout, reference,
|
||||||
|
n_energy_bins=4, n_marginal_bins=50, top_k_pdg=6,
|
||||||
|
sample_rows=min(n_side, 200_000),
|
||||||
|
)
|
||||||
|
ctx.save(shared)
|
||||||
|
|
||||||
|
# warm the OS page cache so the timed pass measures compute, not
|
||||||
|
# the one-time cold read of a freshly-written file.
|
||||||
|
pl.scan_parquet(rollout).select(pl.len()).collect()
|
||||||
|
pl.scan_parquet(reference).select(pl.len()).collect()
|
||||||
|
|
||||||
|
n_rows = 2 * n_side # rollout + reference rows in this "chunk"
|
||||||
|
for spec_id in ids:
|
||||||
|
out = tmp_path / f"{spec_id}_{n_side}.json"
|
||||||
|
dt = _time(spec_id, rollout, reference, shared, out)
|
||||||
|
timings[spec_id].append((n_rows, dt))
|
||||||
|
print(f"{spec_id:35s} n_rows={n_rows:>9d} time={dt:7.3f}s")
|
||||||
|
|
||||||
|
rollout.unlink()
|
||||||
|
reference.unlink()
|
||||||
|
shared.unlink()
|
||||||
|
|
||||||
|
print("\n# spec_id -> (intercept_s, seconds_per_row), fit by least squares")
|
||||||
|
print("_COST_MODEL: dict[str, tuple[float, float]] = {")
|
||||||
|
for spec_id in ids:
|
||||||
|
xs = np.array([n for n, _ in timings[spec_id]], dtype=float)
|
||||||
|
ys = np.array([t for _, t in timings[spec_id]], dtype=float)
|
||||||
|
slope, intercept = np.polyfit(xs, ys, 1)
|
||||||
|
intercept = max(intercept, 0.0)
|
||||||
|
slope = max(slope, 0.0)
|
||||||
|
print(f' "{spec_id}": ({intercept:.6f}, {slope:.9f}),')
|
||||||
|
print("}")
|
||||||
|
|
||||||
|
if _ROUTER_IDS:
|
||||||
|
print(
|
||||||
|
"\n# router_* specs excluded: need a live MoE checkpoint to do real\n"
|
||||||
|
"# work, none available on this machine — see _ROUTER_FIXED_S instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+48
-5
@@ -93,6 +93,15 @@ def test_prep_lays_out_run_dir(tmp_path: Path):
|
|||||||
assert meta.plot_meta["checkpoint"] == "/ckpt/best.pt"
|
assert meta.plot_meta["checkpoint"] == "/ckpt/best.pt"
|
||||||
assert "best.pt" in meta.title
|
assert "best.pt" in meta.title
|
||||||
assert meta.n_chunks == 1
|
assert meta.n_chunks == 1
|
||||||
|
assert meta.rows_per_chunk == [meta.total_rows] # single chunk holds everything
|
||||||
|
assert meta.total_rows == 8 # 5 rollout rows + 3 reference rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_prep_splits_rows_per_chunk(tmp_path: Path):
|
||||||
|
run_dir = _prep(_write_inputs(tmp_path), chunks=2)
|
||||||
|
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||||
|
assert len(meta.rows_per_chunk) == 2
|
||||||
|
assert sum(meta.rows_per_chunk) == meta.total_rows == 8
|
||||||
|
|
||||||
|
|
||||||
def test_compute_one_from_run_dir(tmp_path: Path):
|
def test_compute_one_from_run_dir(tmp_path: Path):
|
||||||
@@ -163,10 +172,12 @@ def test_write_submit_description(tmp_path: Path):
|
|||||||
assert "docker_image = mschnepf/slc7-condocker" in txt
|
assert "docker_image = mschnepf/slc7-condocker" in txt
|
||||||
assert "requirements = TARGET.ProvidesETPResources" in txt
|
assert "requirements = TARGET.ProvidesETPResources" in txt
|
||||||
assert "accounting_group = cms" in txt
|
assert "accounting_group = cms" in txt
|
||||||
assert "queue plotid,chunk from" in txt
|
assert "+RequestWalltime = $(walltime)" in txt
|
||||||
|
assert "queue plotid,chunk,walltime from" in txt
|
||||||
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
||||||
assert [i for i, _ in jobs] == catalog_ids()
|
assert [i for i, _, _ in jobs] == catalog_ids()
|
||||||
assert all(k == "0" for _, k in jobs) # n_chunks=1 default
|
assert all(k == "0" for _, k, _ in jobs) # n_chunks=1 default
|
||||||
|
assert all(int(w) > 0 for _, _, w in jobs)
|
||||||
wrapper = run_dir / "run_compute.sh"
|
wrapper = run_dir / "run_compute.sh"
|
||||||
assert wrapper.exists() and (wrapper.stat().st_mode & 0o111)
|
assert wrapper.exists() and (wrapper.stat().st_mode & 0o111)
|
||||||
body = wrapper.read_text()
|
body = wrapper.read_text()
|
||||||
@@ -186,14 +197,46 @@ def test_write_submit_remote_flag(tmp_path: Path):
|
|||||||
|
|
||||||
def test_write_submit_chunks_respect_chunkable(tmp_path: Path):
|
def test_write_submit_chunks_respect_chunkable(tmp_path: Path):
|
||||||
assert get_spec("router_gating").chunkable is False
|
assert get_spec("router_gating").chunkable is False
|
||||||
run_dir = _prep(_write_inputs(tmp_path))
|
run_dir = _prep(_write_inputs(tmp_path), chunks=4)
|
||||||
cfg = SubmitConfig(
|
cfg = SubmitConfig(
|
||||||
run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=4
|
run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=4
|
||||||
)
|
)
|
||||||
write_submit(cfg)
|
write_submit(cfg)
|
||||||
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
jobs = [line.split(",") for line in (run_dir / "jobs.txt").read_text().split()]
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for spec_id, _ in jobs:
|
for spec_id, _, _ in jobs:
|
||||||
counts[spec_id] = counts.get(spec_id, 0) + 1
|
counts[spec_id] = counts.get(spec_id, 0) + 1
|
||||||
assert counts["marginal_edep"] == 4
|
assert counts["marginal_edep"] == 4
|
||||||
assert counts["router_gating"] == 1 # chunkable=False, ignores n_chunks
|
assert counts["router_gating"] == 1 # chunkable=False, ignores n_chunks
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_runtime_s_scales_with_rows_and_margin():
|
||||||
|
from giant.analysis import RUNTIME_SAFETY_MARGIN, estimate_runtime_s
|
||||||
|
from giant.analysis.runtime_estimate import _FIXED_OVERHEAD_S
|
||||||
|
|
||||||
|
assert RUNTIME_SAFETY_MARGIN > 0
|
||||||
|
small = estimate_runtime_s("marginal_edep", 1_000)
|
||||||
|
large = estimate_runtime_s("marginal_edep", 100_000_000)
|
||||||
|
assert small >= (1 + RUNTIME_SAFETY_MARGIN) * _FIXED_OVERHEAD_S
|
||||||
|
assert large > small # bigger chunk -> longer estimate
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_submit_walltime_grows_with_chunk_rows(tmp_path: Path):
|
||||||
|
"""A chunked run's later job walltimes track that chunk's row count."""
|
||||||
|
from giant.analysis.runtime_estimate import estimate_runtime_s
|
||||||
|
|
||||||
|
run_dir = _prep(_write_inputs(tmp_path), chunks=2)
|
||||||
|
meta = RunMeta.load(run_dir / "run_meta.json")
|
||||||
|
cfg = SubmitConfig(
|
||||||
|
run_dir=run_dir, accounting_group="cms", repo_dir=tmp_path, n_chunks=2
|
||||||
|
)
|
||||||
|
write_submit(cfg)
|
||||||
|
jobs = {
|
||||||
|
(i, int(k)): int(w)
|
||||||
|
for i, k, w in (
|
||||||
|
line.split(",") for line in (run_dir / "jobs.txt").read_text().split()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for chunk in range(2):
|
||||||
|
expected = estimate_runtime_s("marginal_edep", meta.rows_per_chunk[chunk])
|
||||||
|
assert jobs[("marginal_edep", chunk)] == expected
|
||||||
|
|||||||
Reference in New Issue
Block a user