-
Notifications
You must be signed in to change notification settings - Fork 60
SCHED-1897: Add idle node memory health check #2773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
fdcdd1f
SCHED-1897: Add idle node memory health check
2162ec8
SCHED-1897: Detect idle nodes without controller RPC
e0c828c
SCHED-1897: Derive idle memory threshold from RealMemory
ded6702
Use json format + jq
390bf73
Use "free -hw" to print debug messages
1095d51
Change to user_problem
9ae26f5
Simplify the drain reason
e443e0d
Add script to undrain if there is enough memory
b073d46
rewrite mem_used on python
asteny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
243 changes: 243 additions & 0 deletions
243
helm/slurm-cluster/slurm_scripts/idle_mem_used.drain.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| import json | ||
| import logging | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import time | ||
|
|
||
|
|
||
| BYTES_PER_GB = 1_000_000_000 | ||
| LOGGER = logging.getLogger("idle_mem_used") | ||
|
|
||
|
|
||
| class InfoFilter(logging.Filter): | ||
| def filter(self, record: logging.LogRecord) -> bool: | ||
| return record.levelno < logging.WARNING | ||
|
|
||
|
|
||
| def configure_logging() -> None: | ||
| stdout_handler = logging.StreamHandler(sys.stdout) | ||
| stdout_handler.setLevel(logging.DEBUG) | ||
| stdout_handler.addFilter(InfoFilter()) | ||
| stdout_handler.setFormatter(logging.Formatter("%(message)s")) | ||
|
|
||
| stderr_handler = logging.StreamHandler(sys.stderr) | ||
| stderr_handler.setLevel(logging.WARNING) | ||
| stderr_handler.setFormatter(logging.Formatter("%(message)s")) | ||
|
|
||
| LOGGER.setLevel(logging.INFO) | ||
| LOGGER.handlers.clear() | ||
| LOGGER.addHandler(stdout_handler) | ||
| LOGGER.addHandler(stderr_handler) | ||
| LOGGER.propagate = False | ||
|
|
||
|
|
||
| def run_capture(command: list[str]) -> subprocess.CompletedProcess[str]: | ||
| try: | ||
| return subprocess.run( | ||
| command, | ||
| check=False, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.STDOUT, | ||
| text=True, | ||
| ) | ||
| except OSError as e: | ||
| return subprocess.CompletedProcess( | ||
| args=command, | ||
| returncode=127, | ||
| stdout=f"{command[0]}: {e}\n", | ||
| ) | ||
|
|
||
|
|
||
| def run_capture_c_locale(command: list[str]) -> subprocess.CompletedProcess[str]: | ||
| env = os.environ.copy() | ||
| env["LC_ALL"] = "C" | ||
| try: | ||
| return subprocess.run( | ||
| command, | ||
| check=False, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.STDOUT, | ||
| text=True, | ||
| env=env, | ||
| ) | ||
| except OSError as e: | ||
| return subprocess.CompletedProcess( | ||
| args=command, | ||
| returncode=127, | ||
| stdout=f"{command[0]}: {e}\n", | ||
| ) | ||
|
|
||
|
|
||
| def get_local_job_count(listjobs_output: str) -> int | None: | ||
| try: | ||
| data = json.loads(listjobs_output) | ||
| except json.JSONDecodeError: | ||
| return None | ||
|
|
||
| jobs = data.get("jobs") | ||
| if not isinstance(jobs, list): | ||
| return None | ||
|
|
||
| return len(jobs) | ||
|
|
||
|
|
||
| def parse_memory_values(free_bytes_output: str) -> tuple[int, int] | None: | ||
| for line in free_bytes_output.splitlines(): | ||
| fields = line.split() | ||
| if not fields or fields[0] != "Mem:": | ||
| continue | ||
|
|
||
| if len(fields) < 7: | ||
| return None | ||
|
|
||
| if not fields[1].isdecimal() or not fields[6].isdecimal(): | ||
| return None | ||
|
|
||
| mem_total_bytes = int(fields[1]) | ||
| mem_available_bytes = int(fields[6]) | ||
| if mem_available_bytes > mem_total_bytes: | ||
| return None | ||
|
|
||
| return mem_total_bytes, mem_available_bytes | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| def bytes_to_gb(value: int) -> str: | ||
| return f"{value / BYTES_PER_GB:.2f}" | ||
|
|
||
|
|
||
| def log_human_memory_snapshot() -> None: | ||
| LOGGER.info("Memory snapshot (free -hw):") | ||
| result = run_capture_c_locale(["free", "-hw"]) | ||
| if result.stdout: | ||
| LOGGER.info(result.stdout.rstrip("\n")) | ||
|
|
||
| if result.returncode != 0: | ||
| LOGGER.warning( | ||
| "Could not print the human-readable memory snapshot with 'free -hw'" | ||
| ) | ||
|
|
||
|
|
||
| def write_drain_reason(message: str) -> None: | ||
| os.write(3, f"{message}\n".encode("utf-8", errors="backslashreplace")) | ||
|
|
||
|
|
||
| def main() -> int: | ||
| configure_logging() | ||
|
|
||
| node_name = os.environ.get("SLURMD_NODENAME", "unknown") | ||
| node_real_memory_raw = os.environ.get("CHECKS_NODE_REAL_MEM_BYTES", "") | ||
|
|
||
| LOGGER.info("[%s] Check memory usage when node %s is idle", time.ctime(), node_name) | ||
| LOGGER.info( | ||
| "Slurm RealMemory input: " | ||
| "%s bytes", | ||
| node_real_memory_raw if node_real_memory_raw else "<unavailable>", | ||
| ) | ||
|
|
||
| listjobs_result = run_capture(["scontrol", "listjobs", "--json"]) | ||
| listjobs_output = listjobs_result.stdout.rstrip("\n") | ||
|
|
||
| LOGGER.info("scontrol listjobs --json exit code: %s", listjobs_result.returncode) | ||
| LOGGER.info( | ||
| "scontrol listjobs --json output: " | ||
| "%s", | ||
| listjobs_output if listjobs_output else "<empty>", | ||
| ) | ||
|
|
||
| if listjobs_result.returncode != 0: | ||
| LOGGER.warning( | ||
| "Could not determine whether the node is idle because " | ||
| "'scontrol listjobs --json' failed; skipping memory validation" | ||
| ) | ||
| return 0 | ||
|
|
||
| local_job_count = get_local_job_count(listjobs_output) | ||
| if local_job_count is None: | ||
| LOGGER.warning( | ||
| "Could not determine whether the node is idle because " | ||
| "'scontrol listjobs --json' returned invalid job data; " | ||
| "skipping memory validation" | ||
| ) | ||
| return 0 | ||
|
|
||
| LOGGER.info("Local Slurm job count from JSON .jobs array: %s", local_job_count) | ||
| node_is_idle = local_job_count == 0 | ||
| if node_is_idle: | ||
| LOGGER.info("The JSON .jobs array is empty; treating the node as idle") | ||
| else: | ||
| LOGGER.info( | ||
| "The JSON .jobs array contains local jobs; treating the node as non-idle" | ||
| ) | ||
|
|
||
| LOGGER.info("Node is idle: %s", str(node_is_idle).lower()) | ||
| if not node_is_idle: | ||
| LOGGER.info("Node has local jobs; skipping memory validation") | ||
| return 0 | ||
|
|
||
| if not node_real_memory_raw.isdecimal() or int(node_real_memory_raw) <= 0: | ||
| real_memory_display = node_real_memory_raw or "<unavailable>" | ||
| LOGGER.warning( | ||
| "Invalid or unavailable Slurm RealMemory '%s'; expected a positive " | ||
| "byte count, skipping memory validation", | ||
| real_memory_display, | ||
| ) | ||
| return 0 | ||
|
|
||
| node_real_memory_bytes = int(node_real_memory_raw) | ||
| free_bytes_result = run_capture_c_locale(["free", "-b"]) | ||
| if free_bytes_result.returncode != 0: | ||
| LOGGER.warning( | ||
| "Could not read local memory information with 'free -b'; " | ||
| "skipping memory validation" | ||
| ) | ||
| return 0 | ||
|
|
||
| memory_values = parse_memory_values(free_bytes_result.stdout) | ||
| if memory_values is None: | ||
| LOGGER.warning( | ||
| "Could not determine valid total and available memory from 'free -b'; " | ||
| "skipping memory validation" | ||
| ) | ||
| return 0 | ||
|
|
||
| mem_total_bytes, mem_available_bytes = memory_values | ||
|
|
||
| if node_real_memory_bytes > mem_total_bytes: | ||
| LOGGER.warning( | ||
| "Slurm RealMemory %s bytes exceeds MemTotal %s bytes; " | ||
| "skipping memory validation", | ||
| node_real_memory_bytes, | ||
| mem_total_bytes, | ||
| ) | ||
| return 0 | ||
|
|
||
| mem_available_gb = bytes_to_gb(mem_available_bytes) | ||
| node_real_memory_gb = bytes_to_gb(node_real_memory_bytes) | ||
|
|
||
| LOGGER.info( | ||
| "Memory comparison: " | ||
| "available=%s GB (%s bytes), " | ||
| "Slurm RealMemory=%s GB (%s bytes)", | ||
| mem_available_gb, | ||
| mem_available_bytes, | ||
| node_real_memory_gb, | ||
| node_real_memory_bytes, | ||
| ) | ||
| log_human_memory_snapshot() | ||
|
|
||
| if mem_available_bytes < node_real_memory_bytes: | ||
| write_drain_reason( | ||
| f"available memory {mem_available_gb} GB < configured " | ||
| f"{node_real_memory_gb} GB; stop leftover processes or reboot" | ||
| ) | ||
| return 1 | ||
|
|
||
| LOGGER.info("Idle node leaves enough memory available for Slurm RealMemory") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
16 changes: 16 additions & 0 deletions
16
helm/slurm-cluster/slurm_scripts/idle_mem_used.drain.py.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
|
fabrizio2210 marked this conversation as resolved.
|
||
| "name": "idle_mem_used", | ||
| "command": "/usr/bin/python3 ./idle_mem_used.drain.py", | ||
| "platforms": ["any"], | ||
| "skip_for_cpu_jobs": false, | ||
| "skip_for_partial_gpu_jobs": false, | ||
| "contexts": ["hc_program"], | ||
| "node_states": ["any"], | ||
| "on_fail": "drain", | ||
| "on_ok": "none", | ||
| "reason_base": "[user_problem] $name", | ||
|
asteny marked this conversation as resolved.
|
||
| "reason_append_details": true, | ||
| "run_in_jail": false, | ||
| "log": "slurm_scripts/$worker.$name.drain.$context.out", | ||
| "need_env": ["CHECKS_NODE_REAL_MEM_BYTES"] | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.