code for date based histograms with nice bin widths
This commit is contained in:
+167
-8
@@ -49,6 +49,8 @@ import sys
|
||||
import signal
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field, fields, asdict
|
||||
from pathlib import Path
|
||||
@@ -66,6 +68,7 @@ debug = 0
|
||||
# Default GB per CPUs for the cluster
|
||||
default_mempercpu_gb = 2.2
|
||||
|
||||
SLURM_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
||||
SACCT_DELIM = "\x1f" # ASCII Unit Separator
|
||||
|
||||
SACCT_FIELDS = [
|
||||
@@ -78,6 +81,7 @@ SACCT_FIELDS = [
|
||||
"AllocCPUS",
|
||||
"NNodes",
|
||||
"ReqMem",
|
||||
"Start",
|
||||
"ElapsedRaw",
|
||||
"TimelimitRaw",
|
||||
"CPUTimeRAW",
|
||||
@@ -106,6 +110,7 @@ ALL_COLUMNS = [
|
||||
"MaxRSS_max",
|
||||
"MemPerCPU",
|
||||
"Mem_Eff",
|
||||
"Start",
|
||||
"Planned_Time",
|
||||
"Elig_Qtime",
|
||||
"waste_Mem",
|
||||
@@ -257,6 +262,7 @@ HISTO_COLUMNS = {
|
||||
"Elig_Qtime",
|
||||
"Mem_Eff",
|
||||
"Planned_Time",
|
||||
"Start",
|
||||
"Time_Eff",
|
||||
"UsedMem",
|
||||
"Walltime",
|
||||
@@ -431,6 +437,7 @@ class JobRecord:
|
||||
mem_per_cpu_gb: float | None
|
||||
mem_alloc_tres: float | None
|
||||
mem_used_tres: float | None
|
||||
start: float | None
|
||||
planned_sec: float
|
||||
reqwall_hours: float | None
|
||||
reqmem_bytes_total: float | None
|
||||
@@ -509,6 +516,7 @@ class OutputRow:
|
||||
AllocMem: float | None
|
||||
UsedMem: float | None
|
||||
MemPerCPU: float | None
|
||||
Start: float | None
|
||||
ReqWalltime: float | None
|
||||
Planned_Time: float | None
|
||||
Elig_Qtime: float | None
|
||||
@@ -539,6 +547,7 @@ class OutputRow:
|
||||
"UsedMem": self.UsedMem,
|
||||
"MaxRSS_max": self.maxrss_max,
|
||||
"MemPerCPU": self.MemPerCPU,
|
||||
"Start": self.Start,
|
||||
"ReqWalltime": self.ReqWalltime,
|
||||
"Walltime": self.walltime,
|
||||
"Walltime_max": self.walltime_max,
|
||||
@@ -567,7 +576,7 @@ class OutputRow:
|
||||
|
||||
|
||||
def die(msg: str, code: int = 2) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
@@ -597,6 +606,21 @@ def slurm_duration_to_seconds(value: str) -> float:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
def slurm_date_to_epoch(
|
||||
value: str,
|
||||
# *,
|
||||
# timezone: ZoneInfo,
|
||||
) -> float:
|
||||
"""
|
||||
Convert a Slurm timestamp to Unix epoch seconds.
|
||||
|
||||
The sacct timestamp has no timezone offset, so `timezone` must describe
|
||||
the timezone used by the Slurm cluster.
|
||||
"""
|
||||
# Since Slurm does not add timezone info, we consider all values as UTC
|
||||
parsed = datetime.strptime(value, SLURM_TIME_FORMAT)
|
||||
# localized = parsed.replace(tzinfo=timezone)
|
||||
return parsed.timestamp()
|
||||
|
||||
def format_seconds(seconds: int | float | None) -> str:
|
||||
if seconds is None or seconds <= 0:
|
||||
@@ -782,7 +806,11 @@ def read_binary_cache(filename: str) -> dict[str, str or list[JobRecord]]:
|
||||
|
||||
if result['slurm_eff_version'] != VERSION:
|
||||
sys.stderr.write(f"WARNING: cache was written by version {result['slurm_eff_version']}, but we are running version {VERSION}\n")
|
||||
result['records'] = [JobRecord(**d) for d in blob["records"]]
|
||||
try:
|
||||
result['records'] = [JobRecord(**d) for d in blob["records"]]
|
||||
except TypeError as e:
|
||||
sys.stderr.write(f"ERROR reading records from {filename}: {e}\n")
|
||||
sys.exit(1)
|
||||
return result
|
||||
|
||||
def show_cache_info(filename: str):
|
||||
@@ -825,7 +853,7 @@ def build_job_records(rows: list[dict[str, str]]) -> list[JobRecord]:
|
||||
if state.startswith("CANCELLED by"):
|
||||
state = "CANCELLED"
|
||||
stateid = state_mappings[state]
|
||||
|
||||
|
||||
# seff-style practical peak RSS: maximum MaxRSS across non-extern job steps.
|
||||
# This is intentionally not a sum over all ranks/tasks.
|
||||
step_rows = [
|
||||
@@ -869,6 +897,12 @@ def build_job_records(rows: list[dict[str, str]]) -> list[JobRecord]:
|
||||
planned_sec = float(top.get("PlannedCPURAW") or 0)
|
||||
elig_qtime_sec = planned_sec / alloc_cpus
|
||||
|
||||
# Start time by default is in YYYY-MM-DDTHH:MM:SS format.
|
||||
start = None
|
||||
startstr = top.get("Start") or None
|
||||
if startstr:
|
||||
start = slurm_date_to_epoch(startstr)
|
||||
|
||||
# sacct TimelimitRaw is minutes, while ElapsedRaw and CPUTimeRAW are seconds.
|
||||
timelimit_raw_minutes = float(top.get("TimelimitRaw") or 0)
|
||||
timelimit_seconds = int(round(timelimit_raw_minutes * 60))
|
||||
@@ -930,6 +964,7 @@ def build_job_records(rows: list[dict[str, str]]) -> list[JobRecord]:
|
||||
planned_sec=planned_sec,
|
||||
reqwall_hours=reqwall_hours,
|
||||
reqmem_bytes_total=reqmem_total,
|
||||
start=start,
|
||||
elapsed_sec=elapsed,
|
||||
elig_qtime_sec=elig_qtime_sec,
|
||||
timelimit_sec=timelimit_seconds,
|
||||
@@ -1078,6 +1113,8 @@ def make_single_row(rec: JobRecord, dflt_mpcpu: float,
|
||||
vectors[colname] = [rec.elapsed_sec/3600]
|
||||
elif colname == "usedmem" and rec.mem_used_tres is not None:
|
||||
vectors[colname] = [rec.mem_used_tres]
|
||||
elif colname == "start" and rec.start is not None:
|
||||
vectors[colname] = [rec.start]
|
||||
elif colname == "elig_qtime":
|
||||
vectors[colname] = [rec.elig_qtime_sec/3600]
|
||||
elif colname == "planned_time":
|
||||
@@ -1100,6 +1137,7 @@ def make_single_row(rec: JobRecord, dflt_mpcpu: float,
|
||||
AllocMem=rec.mem_alloc_tres,
|
||||
UsedMem=rec.mem_used_tres,
|
||||
MemPerCPU=rec.mem_per_cpu_gb,
|
||||
Start=rec.start,
|
||||
ReqWalltime=rec.reqwall_hours,
|
||||
Planned_Time=rec.planned_sec/3600,
|
||||
Elig_Qtime=rec.elig_qtime_sec/3600,
|
||||
@@ -1128,7 +1166,10 @@ def make_aggregate_row(records: list[JobRecord], username: str, partition: str,
|
||||
mem_eff_vals = [r.mem_eff for r in records if r.mem_eff is not None]
|
||||
time_eff_vals = [r.time_eff for r in records if r.time_eff is not None]
|
||||
|
||||
walltime=avg_function([r.elapsed_sec for r in records if r.elapsed_sec is not None])
|
||||
# an average start date is not very useful, but we do it for consistency
|
||||
start = avg_function([r.start for r in records if r.start is not None])
|
||||
|
||||
walltime = avg_function([r.elapsed_sec for r in records if r.elapsed_sec is not None])
|
||||
if walltime is not None:
|
||||
walltime /= 3600
|
||||
walltime_max=max([r.elapsed_sec for r in records
|
||||
@@ -1189,6 +1230,9 @@ def make_aggregate_row(records: list[JobRecord], username: str, partition: str,
|
||||
elif colname == "usedmem":
|
||||
vectors[colname] = [r.mem_used_tres for r in records
|
||||
if r.mem_used_tres is not None]
|
||||
elif colname == "start":
|
||||
vectors[colname] = [r.start for r in records
|
||||
if r.start is not None]
|
||||
elif colname == "elig_qtime":
|
||||
vectors[colname] = [r.elig_qtime_sec/3600 for r in records
|
||||
if r.elig_qtime_sec is not None]
|
||||
@@ -1213,6 +1257,7 @@ def make_aggregate_row(records: list[JobRecord], username: str, partition: str,
|
||||
AllocMem=alloc_mem,
|
||||
UsedMem=used_mem,
|
||||
MemPerCPU=first.mem_per_cpu_gb,
|
||||
Start=start,
|
||||
ReqWalltime=first.reqwall_hours,
|
||||
Planned_Time=planned_time_h,
|
||||
Elig_Qtime=elig_qtime_h,
|
||||
@@ -1297,6 +1342,8 @@ def format_value(value: Any, column: str | None = None) -> str:
|
||||
if column in ["ReqWalltime", "Walltime", "Walltime_max",
|
||||
"Elig_Qtime", "Planned_Time"]:
|
||||
return f"{value:.2f}h"
|
||||
if column in ["Start"]:
|
||||
return datetime.fromtimestamp(value).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
if isinstance(value, float):
|
||||
return f"{value:.2f}"
|
||||
@@ -1339,7 +1386,6 @@ from math import ceil, exp, floor, log, log10
|
||||
|
||||
Metricnumber = Union[int, float]
|
||||
|
||||
|
||||
def percentile(sorted_values: Sequence[float], p: float) -> float:
|
||||
"""Return percentile p in [0, 100] using linear interpolation."""
|
||||
if not sorted_values:
|
||||
@@ -1440,6 +1486,92 @@ def shared_bins(
|
||||
]
|
||||
|
||||
|
||||
NICE_TIME_BIN_WIDTHS: tuple[int, ...] = (
|
||||
60, # 1 minute
|
||||
5 * 60, # 5 minutes
|
||||
15 * 60, # 15 minutes
|
||||
30 * 60, # 30 minutes
|
||||
60 * 60, # 1 hour
|
||||
2 * 60 * 60, # 2 hours
|
||||
3 * 60 * 60, # 3 hours
|
||||
6 * 60 * 60, # 6 hours
|
||||
12 * 60 * 60, # 12 hours
|
||||
24 * 60 * 60, # 1 day
|
||||
2 * 24 * 60 * 60, # 2 days
|
||||
7 * 24 * 60 * 60, # 1 week
|
||||
14 * 24 * 60 * 60, # 2 weeks
|
||||
30 * 24 * 60 * 60, # approximately 1 month
|
||||
90 * 24 * 60 * 60, # approximately 3 months
|
||||
365 * 24 * 60 * 60, # approximately 1 year
|
||||
)
|
||||
|
||||
|
||||
def nice_time_bin_width(
|
||||
proposed_width: float,
|
||||
*,
|
||||
candidates: tuple[int, ...] = NICE_TIME_BIN_WIDTHS,
|
||||
) -> int:
|
||||
"""
|
||||
Round a proposed width in seconds up to a calendar-friendly width.
|
||||
"""
|
||||
if proposed_width <= 0:
|
||||
raise ValueError("proposed_width must be positive")
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate >= proposed_width:
|
||||
return candidate
|
||||
|
||||
# For very large ranges, round upward to whole years.
|
||||
year = 365 * 24 * 60 * 60
|
||||
return int(-(-proposed_width // year) * year)
|
||||
|
||||
def time_bin_width(values: list[float]) -> int:
|
||||
"""
|
||||
Find appropriate time bin width from first getting an
|
||||
estimate by freedaman-diaconis algorithm, then adapt
|
||||
to nice bin widths.
|
||||
"""
|
||||
width = freedman_diaconis_bin_width(values)
|
||||
logging.debug(f"freedman-diaconis bin width candidate: {width}")
|
||||
|
||||
if width is None:
|
||||
# Sensible fallback for degenerate or very small data.
|
||||
return 24 * 60 * 60
|
||||
|
||||
return nice_time_bin_width(width)
|
||||
|
||||
|
||||
def aligned_time_bin_edges(
|
||||
values: list[float],
|
||||
width: int,
|
||||
) -> list[float]:
|
||||
"""
|
||||
Generate shared, epoch-aligned time-bin edges.
|
||||
|
||||
Bins are aligned to multiples of `width` relative to the Unix epoch.
|
||||
This gives natural alignment for widths such as hours and days in UTC.
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("no values")
|
||||
|
||||
if width <= 0:
|
||||
raise ValueError("width must be positive")
|
||||
|
||||
start = floor(min(values) / width) * width
|
||||
stop = ceil(max(values) / width) * width
|
||||
|
||||
# Ensure that the maximum value is enclosed if it lies exactly on `stop`.
|
||||
if stop <= max(values):
|
||||
stop += width
|
||||
|
||||
number_of_bins = round((stop - start) / width)
|
||||
|
||||
return [
|
||||
float(start + i * width)
|
||||
for i in range(number_of_bins + 1)
|
||||
]
|
||||
|
||||
|
||||
def histogram_counts(
|
||||
values: Iterable[Metricnumber],
|
||||
edges: Sequence[float],
|
||||
@@ -1469,7 +1601,7 @@ def histogram_counts(
|
||||
return counts
|
||||
|
||||
|
||||
def histogram_table(
|
||||
def histogram_metric_table(
|
||||
distributions: Sequence[Sequence[Metricnumber]],
|
||||
*,
|
||||
min_bins: int = 8,
|
||||
@@ -1493,12 +1625,27 @@ def histogram_table(
|
||||
|
||||
return edges, counts
|
||||
|
||||
def histogram_date_table(distributions: Sequence[Sequence[Metricnumber]]):
|
||||
"""
|
||||
Return shared bin edges and bin counts for each date based distribution.
|
||||
"""
|
||||
all_start_times = [timestamp for distr in distributions
|
||||
for timestamp in distr]
|
||||
width = time_bin_width(all_start_times)
|
||||
logging.debug(f"adapting to nice date bin width: {width}")
|
||||
edges = aligned_time_bin_edges(all_start_times, width)
|
||||
|
||||
counts = [histogram_counts(distribution, edges)
|
||||
for distribution in distributions]
|
||||
return edges, counts
|
||||
|
||||
def histo_graphs(out_rows: list[OutputRow], args: argparse.Namespace):
|
||||
# dumb term defaults
|
||||
width = 120
|
||||
height = 30
|
||||
outfileroot=None
|
||||
outfileroot = None
|
||||
term = args.hterm
|
||||
xax_format = ""
|
||||
plotstyle = "set style fill transparent solid 0.35 noborder"
|
||||
linestyle = "with steps lw 2"
|
||||
|
||||
@@ -1524,9 +1671,16 @@ def histo_graphs(out_rows: list[OutputRow], args: argparse.Namespace):
|
||||
|
||||
colnames = args.histo.lower().split(',')
|
||||
for metric in colnames:
|
||||
# TODO: implement the nicer binning for date based x axis (e.g. for Start time)
|
||||
# create bins
|
||||
try:
|
||||
edges, counts = histogram_table([getattr(row,"vectors")[metric] for row in out_rows])
|
||||
if metric in ["start"]:
|
||||
edges, counts = histogram_date_table(
|
||||
[getattr(row,"vectors")[metric] for row in out_rows])
|
||||
pass
|
||||
else:
|
||||
edges, counts = histogram_metric_table(
|
||||
[getattr(row,"vectors")[metric] for row in out_rows])
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"WARNING: failed to produce histogram for {metric}: {e}\n")
|
||||
return
|
||||
@@ -1560,8 +1714,13 @@ def histo_graphs(out_rows: list[OutputRow], args: argparse.Namespace):
|
||||
logy = ""
|
||||
if args.hlogy:
|
||||
logy = "set logscale y\n"
|
||||
if metric == "start":
|
||||
xax_format = (f'set xdata time\n'
|
||||
f'set timefmt "%s"\n'
|
||||
f'set format x "%m-%d\\n%H:%M"\n')
|
||||
|
||||
gnuplot_cmd = (f'set title "{metric} distribution" noenhanced\n'
|
||||
f'{xax_format}'
|
||||
f'set xlabel "{metric}" noenhanced\n'
|
||||
f'set ylabel "jobs"\n'
|
||||
f'{plotstyle}\n'
|
||||
|
||||
Reference in New Issue
Block a user