| | 474 | === Post-workflow analysis in Python === |
| | 475 | |
| | 476 | Here 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 | |
| | 480 | Here 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 | """ |
| | 487 | aggregate_usage.py (SLURM 14.x–compatible) |
| | 488 | ------------------------------------------------------------- |
| | 489 | Combines parent and .batch accounting rows so MaxRSS is preserved. |
| | 490 | |
| | 491 | SLURM 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 | |
| | 497 | Outputs worker_usage.csv: |
| | 498 | JobID, ElapsedSeconds, TotalCPU, MaxRSS_bytes, MaxRSS_MiB, |
| | 499 | ReqMem, NodeList, State |
| | 500 | """ |
| | 501 | |
| | 502 | import subprocess |
| | 503 | import pandas as pd |
| | 504 | import sys |
| | 505 | import re |
| | 506 | from io import StringIO |
| | 507 | |
| | 508 | IDFILE = "worker_ids.txt" |
| | 509 | OUTFILE = "worker_usage.csv" |
| | 510 | |
| | 511 | |
| | 512 | # --------------------------------------------------------- |
| | 513 | # Utilities |
| | 514 | # --------------------------------------------------------- |
| | 515 | def run_cmd(cmd): |
| | 516 | return subprocess.check_output(cmd, shell=True, text=True) |
| | 517 | |
| | 518 | |
| | 519 | def 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 | |
| | 572 | def 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 | # --------------------------------------------------------- |
| | 593 | def 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 | |
| | 659 | if __name__ == "__main__": |
| | 660 | main() |
| | 661 | }}} |
| | 662 | |
| | 663 | ==== Plot progress ==== |
| | 664 | |
| | 665 | Here 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 | """ |
| | 672 | plot_progress.py |
| | 673 | |
| | 674 | Produces: |
| | 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 | |
| | 681 | import pandas as pd |
| | 682 | import matplotlib.pyplot as plt |
| | 683 | import datetime as dt |
| | 684 | import numpy as np |
| | 685 | import glob |
| | 686 | import re |
| | 687 | import os |
| | 688 | |
| | 689 | USAGE_FILE = "worker_usage.csv" |
| | 690 | LOG_GLOB = "log_runner_*.out" |
| | 691 | |
| | 692 | |
| | 693 | # --------------------------------------------------------- |
| | 694 | # Utilities |
| | 695 | # --------------------------------------------------------- |
| | 696 | def 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 | |
| | 717 | def numeric_jobid(s): |
| | 718 | m = re.match(r"^(\d+)", str(s)) |
| | 719 | return int(m.group(1)) if m else None |
| | 720 | |
| | 721 | |
| | 722 | def 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 | |
| | 730 | def 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 | |
| | 740 | def 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 | # --------------------------------------------------------- |
| | 773 | def 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 | |
| | 906 | if __name__ == "__main__": |
| | 907 | main() |
| | 908 | }}} |
| | 909 | |