#!/usr/bin/env python3 """ SLURM Cluster Monitoring Tool Provides comprehensive overview of cluster usage, resource allocation, and potential issues. """ import subprocess import json import sys from collections import defaultdict from datetime import datetime import argparse class ClusterMonitor: def __init__(self, verbose=False): self.verbose = verbose self.timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") def run_command(self, cmd): """Execute a shell command and return output.""" try: result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode != 0 and self.verbose: print(f"Warning: Command failed: {cmd}") print(f"Error: {result.stderr}") return result.stdout except Exception as e: if self.verbose: print(f"Error running command: {e}") return "" def get_node_summary(self): """Get summary of node states.""" cmd = "sinfo -h -N -o '%T' | sort | uniq -c" output = self.run_command(cmd) states = {} total_nodes = 0 for line in output.strip().split("\n"): if line: parts = line.strip().split() if len(parts) == 2: count, state = parts states[state] = int(count) total_nodes += int(count) return states, total_nodes def get_gpu_usage_by_user(self): """Calculate GPU usage per user.""" # Get running jobs with user and node info cmd = "squeue -h -t RUNNING -o '%.30u %.8D %.6C %.200R'" output = self.run_command(cmd) user_stats = defaultdict( lambda: {"nodes": 0, "cpus": 0, "gpus": 0, "node_list": []} ) for line in output.strip().split("\n"): if line: parts = line.strip().split() if len(parts) >= 4: user = parts[0] nodes = int(parts[1]) cpus = int(parts[2]) node_list = parts[3] # Assuming 8 GPUs per node for H100 nodes gpus = nodes * 8 user_stats[user]["nodes"] += nodes user_stats[user]["cpus"] += cpus user_stats[user]["gpus"] += gpus user_stats[user]["node_list"].append(node_list) return dict(user_stats) def get_idle_nodes(self): """Get list of idle nodes.""" cmd = "sinfo -h -t idle -N -o '%N'" output = self.run_command(cmd) idle_nodes = [] for line in output.strip().split("\n"): if line: idle_nodes.append(line.strip()) return idle_nodes def get_drained_nodes(self): """Get drained/down nodes with reasons.""" cmd = "sinfo -R -o '%50E %20H %10T %N'" output = self.run_command(cmd) drained = [] lines = output.strip().split("\n") for line in lines[1:]: # Skip header if line and ("drain" in line.lower() or "down" in line.lower()): drained.append(line.strip()) return drained def get_queue_summary(self): """Get job queue summary.""" cmd = "squeue -h -o '%T' | sort | uniq -c" output = self.run_command(cmd) queue_stats = {} for line in output.strip().split("\n"): if line: parts = line.strip().split() if len(parts) == 2: count, state = parts queue_stats[state] = int(count) return queue_stats def get_running_jobs_with_commands(self): """Get running jobs with their launch commands, workdir, and stdout.""" # Get basic job info cmd = "squeue -h -t RUNNING -o '%.10i|%.15u|%.6D|%.6C|%.10M|%.50j'" output = self.run_command(cmd) jobs = [] for line in output.strip().split("\n"): if line: parts = line.split("|") if len(parts) >= 6: try: job_id = parts[0].strip() # Start with basic info from squeue job_info = { "id": job_id, "user": parts[1].strip(), "nodes": int(parts[2].strip()), "cpus": int(parts[3].strip()), "time": parts[4].strip(), "name": parts[5].strip() if len(parts) > 5 else "N/A", "command": "N/A", "workdir": "N/A", "stdout": "N/A", } # Try to get detailed info from scontrol cmd_detail = f"scontrol show job {job_id} 2>/dev/null" detail_output = self.run_command(cmd_detail) # Parse details if available if detail_output and "JobId=" in detail_output: for detail_line in detail_output.split("\n"): if "Command=" in detail_line: command = detail_line.split("Command=")[1].strip() if "/" in command: job_info["command"] = command.split("/")[-1] else: job_info["command"] = command elif "WorkDir=" in detail_line: workdir = ( detail_line.split("WorkDir=")[1] .split()[0] .strip() ) # Shorten workdir path for display if len(workdir) > 30: path_parts = workdir.split("/") if len(path_parts) > 3: job_info["workdir"] = ( f".../{'/'.join(path_parts[-2:])}" ) else: job_info["workdir"] = workdir else: job_info["workdir"] = workdir elif "StdOut=" in detail_line: stdout = ( detail_line.split("StdOut=")[1] .split()[0] .strip() ) # Extract just the filename if "/" in stdout: job_info["stdout"] = stdout.split("/")[-1] else: job_info["stdout"] = stdout elif self.verbose: print(f"Warning: Could not get details for job {job_id}") jobs.append(job_info) except (ValueError, IndexError) as e: if self.verbose: print(f"Warning: Error parsing job entry: {e}") return jobs def get_long_running_jobs(self, days=3): """Get jobs running longer than specified days.""" cmd = f"squeue -h -t RUNNING -o '%.10i %.15u %.8T %.15M %.6D %.50j' | awk '$4 ~ /-/ && int($4) >= {days}'" output = self.run_command(cmd) long_jobs = [] for line in output.strip().split("\n"): if line: long_jobs.append(line.strip()) return long_jobs def get_partition_summary(self): """Get detailed partition information.""" cmd = "sinfo -o '%20P %5D %10T %5c %10m %10a'" output = self.run_command(cmd) return output def trim_middle(self, text, max_len=20): """Trim long text with ... in the middle.""" if len(text) <= max_len: return text if max_len <= 3: return text[:max_len] # Calculate how many chars to keep on each side keep_chars = (max_len - 3) // 2 left_chars = keep_chars right_chars = max_len - 3 - left_chars return f"{text[:left_chars]}...{text[-right_chars:]}" def print_report(self): """Print comprehensive cluster report.""" print("=" * 80) print(f"SLURM CLUSTER MONITORING REPORT - {self.timestamp}") print("=" * 80) # Node Summary print("\nšŸ“Š NODE STATUS SUMMARY") print("-" * 40) states, total = self.get_node_summary() # Define the desired order: allocated, mixed, idle, drained (and any others) ordered_states = ["allocated", "mixed", "idle", "drained"] # Print states in the specified order for state in ordered_states: if state in states: count = states[state] percentage = (count / total * 100) if total > 0 else 0 status_icon = "āœ…" if state in ["idle", "mixed", "allocated"] else "āš ļø" print(f"{status_icon} {state:15} {count:4} nodes ({percentage:5.1f}%)") # Print any other states that weren't in our ordered list for state, count in sorted(states.items()): if state not in ordered_states: percentage = (count / total * 100) if total > 0 else 0 status_icon = "āš ļø" # Unknown states get warning icon print(f"{status_icon} {state:15} {count:4} nodes ({percentage:5.1f}%)") print(f"\n{'Total nodes:':15} {total:4}") # GPU Usage by User print("\nšŸ‘„ GPU ALLOCATION BY USER") print("-" * 40) user_stats = self.get_gpu_usage_by_user() if user_stats: # Sort by GPU count sorted_users = sorted( user_stats.items(), key=lambda x: x[1]["gpus"], reverse=True ) print(f"{'User':<15} {'Nodes':>8} {'GPUs':>8} {'CPUs':>8}") print("-" * 40) total_gpus = 0 for user, stats in sorted_users: print( f"{user:<15} {stats['nodes']:>8} {stats['gpus']:>8} {stats['cpus']:>8}" ) total_gpus += stats["gpus"] print("-" * 40) print( f"{'TOTAL':<15} {sum(s['nodes'] for s in user_stats.values()):>8} " f"{total_gpus:>8} {sum(s['cpus'] for s in user_stats.values()):>8}" ) else: print("No running jobs found.") # Show running jobs with commands running_jobs = self.get_running_jobs_with_commands() if running_jobs: print("\nšŸ“Š RUNNING JOBS DETAIL") print("-" * 140) print( f"{'JobID':<10} {'User':<10} {'Nodes':<6} {'GPUs':<6} {'Time':<12} {'Command':<30} {'WorkDir':<30} {'Output':<25} {'ā° >3d':<5}" ) print("-" * 140) total_nodes = 0 for job in running_jobs: gpus = job["nodes"] * 8 total_nodes += job["nodes"] # Check if job is long-running (>3 days) time_str = job["time"] long_running = "" if "-" in time_str: days = int(time_str.split("-")[0]) if days >= 3: long_running = "ā°" # Trim long strings with ... in the middle command = self.trim_middle(job["command"], 28) workdir = self.trim_middle(job["workdir"], 28) stdout = self.trim_middle(job["stdout"], 23) print( f"{job['id']:<10} {job['user']:<10} {job['nodes']:<6} {gpus:<6} {job['time']:<12} {command:<30} {workdir:<30} {stdout:<25} {long_running:<5}" ) print("-" * 140) total_gpus = total_nodes * 8 print( f"{'TOTAL':<10} {len(running_jobs)} jobs{'':<6} {total_nodes:<6} {total_gpus:<6}" ) # Problem Nodes print("\nāš ļø DRAINED NODES (Dev, or Down if no Reason)") print("-" * 80) drained = self.get_drained_nodes() if drained: print(f"Total: {len(drained)} problem nodes") print("-" * 80) print(f"{'Reason':<50} {'Timestamp':<20} {'State':<10} {'Nodes'}") print("-" * 80) for node_info in drained: # Show all problem nodes print(node_info) else: print("No problem nodes detected") print("\n" + "=" * 80) print("END OF REPORT") print("=" * 80) def export_json(self, filename=None): """Export monitoring data to JSON.""" if not filename: filename = f"cluster_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" data = { "timestamp": self.timestamp, "node_states": dict(self.get_node_summary()[0]), "user_stats": self.get_gpu_usage_by_user(), "idle_nodes_count": len(self.get_idle_nodes()), "queue_summary": self.get_queue_summary(), "problem_nodes_count": len(self.get_drained_nodes()), } with open(filename, "w") as f: json.dump(data, f, indent=2) return filename def main(): parser = argparse.ArgumentParser(description="SLURM Cluster Monitoring Tool") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-j", "--json", help="Export to JSON file") parser.add_argument( "-c", "--continuous", type=int, metavar="SECONDS", help="Run continuously with specified interval", ) args = parser.parse_args() monitor = ClusterMonitor(verbose=args.verbose) if args.continuous: import time try: while True: # Clear screen print("\033[2J\033[H") monitor.print_report() if args.json: filename = monitor.export_json(args.json) print(f"\nšŸ“ Report exported to: {filename}") print( f"\nšŸ”„ Refreshing in {args.continuous} seconds... (Ctrl+C to stop)" ) time.sleep(args.continuous) except KeyboardInterrupt: print("\n\nMonitoring stopped.") else: monitor.print_report() if args.json: filename = monitor.export_json(args.json) print(f"\nšŸ“ Report exported to: {filename}") if __name__ == "__main__": main()