Changes between Version 7 and Version 8 of Workshops/JobCheckpointing/Examples


Ignore:
Timestamp:
04/02/2026 09:04:44 PM (4 months ago)
Author:
Carl Baribault
Comment:

Added Python scripts for post-workflow analysis

Legend:

Unmodified
Added
Removed
Modified
  • Workshops/JobCheckpointing/Examples

    v7 v8  
    472472}}}
    473473
     474=== Post-workflow analysis in Python ===
     475
     476Here are two Python scripts used by the checkpoint submitter to analyze and plot usage and annotate anomalies in execution time and memory usage by the checkpoint runner jobs.
     477
     478==== Aggregate usage ====
     479
     480Here is the listing for the file '''aggregate_usage.py''' used in [wiki://Workshops/JobCheckpointing/Examples#checkpoint_submitter.sh checkpoint_submitter.sh].
     481
     482===== aggregate_usage.py =====
     483
     484{{{
     485#!/usr/bin/env python3
     486"""
     487aggregate_usage.py  (SLURM 14.x–compatible)
     488-------------------------------------------------------------
     489Combines parent and .batch accounting rows so MaxRSS is preserved.
     490
     491SLURM 14.x specifics:
     492  - Only .batch steps contain correct MaxRSS
     493  - Parent jobs often have empty MaxRSS
     494  - ElapsedRaw is not available
     495  - Elapsed may be MM, MM:SS, HH:MM:SS, D-HH, D-HH:MM, D-HH:MM:SS
     496
     497Outputs worker_usage.csv:
     498  JobID, ElapsedSeconds, TotalCPU, MaxRSS_bytes, MaxRSS_MiB,
     499  ReqMem, NodeList, State
     500"""
     501
     502import subprocess
     503import pandas as pd
     504import sys
     505import re
     506from io import StringIO
     507
     508IDFILE = "worker_ids.txt"
     509OUTFILE = "worker_usage.csv"
     510
     511
     512# ---------------------------------------------------------
     513# Utilities
     514# ---------------------------------------------------------
     515def run_cmd(cmd):
     516    return subprocess.check_output(cmd, shell=True, text=True)
     517
     518
     519def parse_elapsed_to_seconds(elapsed):
     520    if not isinstance(elapsed, str):
     521        return None
     522
     523    s = elapsed.strip()
     524    days = 0
     525
     526    # Day prefix
     527    if "-" in s:
     528        d, rest = s.split("-", 1)
     529        try:
     530            days = int(d)
     531        except:
     532            return None
     533    else:
     534        rest = s
     535
     536    parts = rest.split(":")
     537
     538    if len(parts) == 1:      # MM or D-HH
     539        try:
     540            x = int(parts[0])
     541        except:
     542            return None
     543        if days > 0:
     544            hours, minutes, seconds = x, 0, 0
     545        else:
     546            hours, minutes, seconds = 0, x, 0
     547
     548    elif len(parts) == 2:    # MM:SS or D-HH:MM
     549        try:
     550            a = int(parts[0])
     551            b = int(parts[1])
     552        except:
     553            return None
     554        if days > 0:
     555            hours, minutes, seconds = a, b, 0
     556        else:
     557            hours, minutes, seconds = 0, a, b
     558
     559    elif len(parts) == 3:    # HH:MM:SS or D-HH:MM:SS
     560        try:
     561            hours = int(parts[0])
     562            minutes = int(parts[1])
     563            seconds = int(parts[2])
     564        except:
     565            return None
     566    else:
     567        return None
     568
     569    return days*86400 + hours*3600 + minutes*60 + seconds
     570
     571
     572def parse_rss_to_bytes(val):
     573    if val is None or pd.isna(val):
     574        return None
     575    s = str(val).strip().rstrip("nN")
     576    try:
     577        if s.endswith(("K","k")):
     578            return float(s[:-1]) * 1e3
     579        if s.endswith(("M","m")):
     580            return float(s[:-1]) * 1e6
     581        if s.endswith(("G","g")):
     582            return float(s[:-1]) * 1e9
     583        if re.fullmatch(r"\d+(\.\d+)?", s):
     584            return float(s)
     585    except:
     586        return None
     587    return None
     588
     589
     590# ---------------------------------------------------------
     591# Main
     592# ---------------------------------------------------------
     593def main():
     594
     595    try:
     596        with open(IDFILE) as f:
     597            jobids = [x.strip() for x in f if x.strip()]
     598    except:
     599        print(f"ERROR: {IDFILE} not found.")
     600        sys.exit(1)
     601
     602    if not jobids:
     603        print("No job IDs to process.")
     604        sys.exit(0)
     605
     606    fields = [
     607        "JobID", "Elapsed", "TotalCPU", "MaxRSS",
     608        "ReqMem", "NodeList", "State"
     609    ]
     610
     611    cmd = f"sacct -j {','.join(jobids)} --format={','.join(fields)} -P"
     612    raw = run_cmd(cmd)
     613
     614    df = pd.read_csv(StringIO(raw), sep="|")
     615    df.dropna(how="all", inplace=True)
     616
     617    # Split BaseID with explicit keywords (older pandas compatibility)
     618    df["BaseID"] = (
     619        df["JobID"].astype(str)
     620        .str.split(pat=".", n=1, regex=False)
     621        .str[0]
     622    )
     623
     624    df["IsBatch"] = df["JobID"].astype(str).str.endswith(".batch")
     625
     626    parent = df[~df["IsBatch"]].copy()
     627    batch  = df[df["IsBatch"]].copy()
     628
     629    merged = pd.merge(
     630        parent,
     631        batch[["BaseID","MaxRSS"]],
     632        on="BaseID",
     633        how="left",
     634        suffixes=("", "_batch")
     635    )
     636
     637    def choose(x):
     638        if pd.notna(x["MaxRSS"]):
     639            return x["MaxRSS"]
     640        return x["MaxRSS_batch"]
     641
     642    merged["MaxRSS"] = merged.apply(choose, axis=1)
     643
     644    merged["MaxRSS_bytes"] = merged["MaxRSS"].apply(parse_rss_to_bytes)
     645    merged["MaxRSS_MiB"] = merged["MaxRSS_bytes"] / (1024.0**2)
     646
     647    merged["ElapsedSeconds"] = merged["Elapsed"].apply(parse_elapsed_to_seconds)
     648
     649    out = merged[[
     650        "BaseID","ElapsedSeconds","TotalCPU","MaxRSS","MaxRSS_bytes",
     651        "MaxRSS_MiB","ReqMem","NodeList","State"
     652    ]].rename(columns={"BaseID": "JobID"})
     653
     654    out.to_csv(OUTFILE, index=False)
     655    print(f"Wrote {OUTFILE}")
     656    print(out.head())
     657
     658
     659if __name__ == "__main__":
     660    main()
     661}}}
     662
     663==== Plot progress ====
     664
     665Here is the listing for the file '''plot_progress.py''' used in [wiki://Workshops/JobCheckpointing/Examples#checkpoint_submitter.sh checkpoint_submitter.sh] for generating .png plot files for run time and memory usage by the checkpoint runner jobs.
     666
     667===== plot_progress.py =====
     668
     669{{{
     670#!/usr/bin/env python3
     671"""
     672plot_progress.py
     673
     674Produces:
     675  - progress_with_anomalies.png     (dual-axis: checkpoints + MaxRSS MiB)
     676  - resource_trend.png              (dual-axis: ElapsedSeconds + MaxRSS MiB)
     677  - anomaly_details.csv             (per-job anomalies)
     678  - anomaly_stats_table.png         (summary table)
     679"""
     680
     681import pandas as pd
     682import matplotlib.pyplot as plt
     683import datetime as dt
     684import numpy as np
     685import glob
     686import re
     687import os
     688
     689USAGE_FILE = "worker_usage.csv"
     690LOG_GLOB   = "log_runner_*.out"
     691
     692
     693# ---------------------------------------------------------
     694# Utilities
     695# ---------------------------------------------------------
     696def parse_worker_logs():
     697    rows = []
     698    for fp in glob.glob(LOG_GLOB):
     699        m = re.search(r"log_runner_(\d+)\.out", os.path.basename(fp))
     700        if not m: continue
     701        jid = int(m.group(1))
     702
     703        with open(fp, "r", errors="ignore") as f:
     704            for line in f:
     705                if "Info[" in line:
     706                    try:
     707                        ts = line.split("Info[")[1].split("]")[0]
     708                        t  = dt.datetime.strptime(ts,"%Y%m%d-%H:%M:%S")
     709                        rows.append((jid,t))
     710                        break
     711                    except:
     712                        pass
     713    return pd.DataFrame(rows, columns=["JobID","StartTime"]) if rows else \
     714           pd.DataFrame(columns=["JobID","StartTime"])
     715
     716
     717def numeric_jobid(s):
     718    m = re.match(r"^(\d+)", str(s))
     719    return int(m.group(1)) if m else None
     720
     721
     722def time_anomalies(elapsed):
     723    s = elapsed.astype(float)
     724    if s.std(ddof=0)==0:
     725        return pd.Series(False,index=s.index), pd.Series(0.0,index=s.index)
     726    z = (s-s.mean())/s.std(ddof=0)
     727    return (z.abs()>3.5), z
     728
     729
     730def mem_anomalies(mem_mib, window=10, thresh=3.5):
     731    s = mem_mib.astype(float)
     732    med = s.rolling(window,min_periods=3).median()
     733    absd = (s-med).abs()
     734    mad  = absd.rolling(window,min_periods=3).median()
     735    modz = 0.6745 * absd / mad.replace(0,np.nan)
     736    modz = modz.fillna(0)
     737    return (modz>thresh), modz
     738
     739
     740def save_summary(summary, details, fname="anomaly_stats_table.png"):
     741    fig, ax = plt.subplots(figsize=(11,6))
     742    ax.axis("off")
     743
     744    rows = [[k,str(v)] for k,v in summary.items()]
     745    stab = ax.table(
     746        cellText=rows,
     747        colLabels=["Metric","Value"],
     748        bbox=[0,0.55,0.45,0.4]
     749    )
     750    stab.auto_set_font_size(False)
     751    stab.set_fontsize(11)
     752
     753    anom = details[details["AnomalyType"]!="none"]
     754    if len(anom)>12: anom = anom.head(12)
     755    if not anom.empty:
     756        atab = ax.table(
     757            cellText=anom.astype(str).values,
     758            colLabels=list(anom.columns),
     759            bbox=[0,0.05,1,0.45]
     760        )
     761        atab.auto_set_font_size(False)
     762        atab.set_fontsize(9)
     763
     764    fig.tight_layout()
     765    fig.savefig(fname,dpi=150)
     766    plt.close(fig)
     767    print(f"Saved {fname}")
     768
     769
     770# ---------------------------------------------------------
     771# Main
     772# ---------------------------------------------------------
     773def main():
     774
     775    df = pd.read_csv(USAGE_FILE)
     776    df = df[df["ElapsedSeconds"].notnull()].reset_index(drop=True)
     777
     778    if "MaxRSS_MiB" not in df.columns:
     779        print("ERROR: worker_usage.csv missing MaxRSS_MiB.")
     780        return
     781
     782    df["JobID"] = df["JobID"].apply(numeric_jobid)
     783    df = df[df["JobID"].notnull()].reset_index(drop=True)
     784
     785    logs = parse_worker_logs()
     786    merged = pd.merge(df, logs, on="JobID", how="inner")
     787
     788    if merged.empty:
     789        print("ERROR: No matching logs.")
     790        return
     791
     792    merged = merged.sort_values("StartTime").reset_index(drop=True)
     793
     794    times   = merged["StartTime"].tolist()
     795    idxs    = np.arange(len(merged))
     796    elapsed = merged["ElapsedSeconds"].astype(float)
     797    mem_mib = merged["MaxRSS_MiB"].astype(float)
     798
     799    # Detect anomalies
     800    mtime, ztime = time_anomalies(elapsed)
     801    mmem,  zmem  = mem_anomalies(mem_mib)
     802
     803    both  = mtime & mmem
     804    tonly = mtime & ~mmem
     805    monly = mmem  & ~mtime
     806
     807    # -----------------------------------------------------
     808    # 1. progress_with_anomalies.png
     809    # -----------------------------------------------------
     810    fig, ax1 = plt.subplots(figsize=(12,6))
     811
     812    # Checkpoint curve with dots
     813    ax1.plot(times, idxs, 'o-', color="gray", label="Checkpoint Progress")
     814    ax1.set_xlabel("Wall Time")
     815    ax1.set_ylabel("Checkpoint Number",color="gray")
     816
     817    # Anomaly markers
     818    for i,t in enumerate(times):
     819        if both[i]:
     820            ax1.plot(t, idxs[i], 'o', color="purple", markersize=10,
     821                     label="Both anomalies" if i==0 else "")
     822        elif tonly[i]:
     823            ax1.plot(t, idxs[i], 'o', color="orange", markersize=9,
     824                     label="Time anomaly" if i==0 else "")
     825        elif monly[i]:
     826            ax1.plot(t, idxs[i], 'o', color="blue", markersize=9,
     827                     label="Memory anomaly" if i==0 else "")
     828
     829    # MaxRSS dual-axis with dots
     830    ax2 = ax1.twinx()
     831    ax2.plot(times, mem_mib.values, 'bo-', markersize=5,
     832             alpha=0.70, label="MaxRSS (MiB)")
     833    ax2.set_ylabel("MaxRSS (MiB)", color="blue")
     834
     835    # Highlight memory anomalies on memory curve
     836    for i,t in enumerate(times):
     837        if monly[i]:
     838            ax2.plot(t, mem_mib.values[i], 'bo', markersize=8)
     839        elif both[i]:
     840            ax2.plot(t, mem_mib.values[i], 'mo', markersize=10)
     841
     842    h1,l1 = ax1.get_legend_handles_labels()
     843    h2,l2 = ax2.get_legend_handles_labels()
     844    ax1.legend(h1+h2, l1+l2, loc="upper left")
     845
     846    fig.tight_layout()
     847    fig.savefig("progress_with_anomalies.png")
     848    plt.close(fig)
     849    print("Saved progress_with_anomalies.png")
     850
     851    # -----------------------------------------------------
     852    # 2. resource_trend.png  (Elapsed vs MaxRSS dual-axis)
     853    # -----------------------------------------------------
     854    fig2, axL = plt.subplots(figsize=(12,6))
     855
     856    axL.plot(elapsed.values, 'ko-', markersize=5,
     857             label="Elapsed (sec)")
     858    axL.set_xlabel("Worker Job Index (time-ordered)")
     859    axL.set_ylabel("Elapsed Seconds",color="black")
     860
     861    axR = axL.twinx()
     862    axR.plot(mem_mib.values, 'bo-', markersize=5,
     863             alpha=0.7, label="MaxRSS (MiB)")
     864    axR.set_ylabel("MaxRSS (MiB)",color="blue")
     865
     866    hL,lL = axL.get_legend_handles_labels()
     867    hR,lR = axR.get_legend_handles_labels()
     868    axL.legend(hL+hR, lL+lR, loc="upper left")
     869
     870    fig2.tight_layout()
     871    fig2.savefig("resource_trend.png")
     872    plt.close(fig2)
     873    print("Saved resource_trend.png")
     874
     875    # -----------------------------------------------------
     876    # 3. anomaly_details + summary table
     877    # -----------------------------------------------------
     878    details = pd.DataFrame({
     879        "JobID": merged["JobID"],
     880        "StartTime": merged["StartTime"],
     881        "ElapsedSeconds": elapsed,
     882        "MaxRSS_MiB": mem_mib,
     883        "TimeZ": ztime,
     884        "MemModZ": zmem
     885    })
     886
     887    atype = np.where(both, "both",
     888             np.where(tonly, "time",
     889             np.where(monly,"memory","none")))
     890    details["AnomalyType"] = atype
     891
     892    summary = {
     893        "Total jobs": len(details),
     894        "Anomalies": int((atype!="none").sum()),
     895        "Time only": int((atype=="time").sum()),
     896        "Memory only": int((atype=="memory").sum()),
     897        "Both": int((atype=="both").sum())
     898    }
     899
     900    details.to_csv("anomaly_details.csv", index=False)
     901    print("Saved anomaly_details.csv")
     902
     903    save_summary(summary, details)
     904
     905
     906if __name__ == "__main__":
     907    main()
     908}}}
     909
    474910== Working Examples ==
    475911
    476 === Make copies of checkpoint_submitter.sh and checkpoint_runner.sh
    477 
    478 All of the following examples make use of the two job scripts mentioned above.
    479 
    480 * the [wiki://Workshops/JobCheckpointing/Examples#checkpoint_submitter.sh checkpoint_submitter.sh] and
    481 * the [wiki://Workshops/JobCheckpointing/Examples#checkpoint_runner.sh checkpoint_runner.sh]
     912=== Make copies of the BASH scripts checkpoint_submitter.sh and checkpoint_runner.sh
     913
     914All of the examples linked further below make use of the following two (2) BASH job scripts mentioned above.
     915
     916* [wiki://Workshops/JobCheckpointing/Examples#checkpoint_submitter.sh checkpoint_submitter.sh]
     917* [wiki://Workshops/JobCheckpointing/Examples#checkpoint_runner.sh checkpoint_runner.sh]
     918
     919=== Make copies of the Python scripts aggregate_usage.py and plot_progress.py
     920
     921Also, the '''checkpoint_submitter.sh''' script calls out the following two (2) Python scripts for post-workflow analysis of execution time and memory usage by the checkpoint runner jobs.
     922
     923* [wiki://Workshops/JobCheckpointing/Examples#aggregate_usage.py aggregate_usage.py]
     924* [wiki://Workshops/JobCheckpointing/Examples#plot_progress.py plot_progress.py]
    482925
    483926=== BASH Checkpointing Example ===