Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
push:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Set up Python
run: uv python install 3.10

- name: Install dependencies
run: uv sync --dev

- name: Run tests
run: PYTHONPATH=src uv run pytest -q
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

## [0.4.1] - 2026-02-23

### Added
- Regression tests for GPU parsing in `slurm_backend`, including realistic `scontrol show node -o` fixture coverage and obfuscated `squeue` job cases.
- CI workflow running tests on pull requests and pushes to `main`.

### Fixed
- Correct GPU parsing for typed GRES values such as `gpu:h100:1` and `gres/gpu:h100:1` so model digits are not misread as GPU counts.
- Preserve per-node GPU semantics in job parsing (`per-node * nodes`) while keeping `gpu_total`/`total_gpu` as explicit totals.

## [0.4.0] - 2026-02-23

### Added
Expand Down Expand Up @@ -62,7 +72,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
### Added
- Initial `smon` release.

[Unreleased]: https://github.com/RuHae/smon/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/RuHae/smon/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/RuHae/smon/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/RuHae/smon/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/RuHae/smon/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/RuHae/smon/compare/v0.1.0...v0.2.0
Expand Down
3 changes: 3 additions & 0 deletions fixtures/scontrol.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
[dependency-groups]
dev = [
"pyinstaller>=6.18.0",
"pytest>=8.4.0",
]

[project.scripts]
Expand Down
68 changes: 43 additions & 25 deletions src/slurm_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@
from fake_slurm_fixtures import run_fake_slurm_command
from smon_config import USE_FAKE_DATA


GPU_GRES_COUNT_PATTERN = re.compile(
r"(?:^|,)\s*(?:gres/)?gpu:(?:(?:[^:,()\s]+):)?(\d+)(?=$|,|\()"
)
GPU_ALLOC_TRES_PATTERN = re.compile(r"(?:^|,)\s*gres/gpu(?::[^=,\s]+)?=(\d+)(?=$|,)")
GPU_TOTAL_PATTERN = re.compile(r"(?:gpu_total|total_gpu)[:=](\d+)")


def _parse_gres_gpu_count(gres_value: str) -> int:
return sum(int(count) for count in GPU_GRES_COUNT_PATTERN.findall(gres_value))


def _parse_alloc_tres_gpu_count(alloc_tres: str) -> int:
return sum(int(count) for count in GPU_ALLOC_TRES_PATTERN.findall(alloc_tres))


def _parse_gpu_total(gpu_field: str) -> int | None:
match = GPU_TOTAL_PATTERN.search(gpu_field)
if match:
return int(match.group(1))
return None


def _parse_gpu_per_node(gpu_field: str) -> int | None:
counts = GPU_GRES_COUNT_PATTERN.findall(gpu_field)
if counts:
return sum(int(count) for count in counts)
return None


def run_slurm_command(cmd: str) -> str:
if USE_FAKE_DATA:
return run_fake_slurm_command(cmd)
Expand Down Expand Up @@ -35,18 +65,10 @@ def get_cluster_stats():
m_u = int(data.get("AllocMem", 0))
m_t = int(data.get("RealMemory", 1))

g_t, g_u = 0, 0
gres_str = data.get("Gres", "")
if "gpu" in gres_str:
parts = re.findall(r":(\d+)", gres_str)
if parts:
g_t = int(parts[0])

alloc_tres = data.get("AllocTRES", "")
if "gres/gpu" in alloc_tres:
match = re.search(r"gres/gpu[^=]*=(\d+)", alloc_tres)
if match:
g_u = int(match.group(1))
g_t = _parse_gres_gpu_count(gres_str)
g_u = _parse_alloc_tres_gpu_count(alloc_tres)

t_cpu_u += c_u
t_cpu_t += c_t
Expand Down Expand Up @@ -97,22 +119,18 @@ def get_job_stats():

gpu_count = "-"
gpu_field = parts[8]
if "gpu" in gpu_field:
try:
# Support explicit total notation in fixtures: gpu_total=64
total_match = re.search(
r"(?:gpu_total|total_gpu|gres/gpu)[:=](\d+)", gpu_field
)
if total_match:
gpu_count = total_match.group(1)
else:
try:
# Fixture compatibility: explicit total markers are whole-job totals.
explicit_total = _parse_gpu_total(gpu_field)
if explicit_total is not None:
gpu_count = str(explicit_total)
else:
per_node = _parse_gpu_per_node(gpu_field)
if per_node is not None:
node_mult = int(parts[6])
per_node_match = re.search(r"gpu[^0-9]*(\d+)", gpu_field)
if per_node_match:
per_node = int(per_node_match.group(1))
gpu_count = str(node_mult * per_node)
except Exception:
pass
gpu_count = str(node_mult * per_node)
except Exception:
pass

dep = parts[16]
if dep == "(null)" or dep == "N/A":
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pathlib import Path
import sys

SRC_PATH = Path(__file__).resolve().parents[1] / "src"
if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH))
11 changes: 11 additions & 0 deletions tests/fixture_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

import json
from pathlib import Path

FIXTURE_PATH = Path(__file__).resolve().parents[1] / "fixtures" / "scontrol.json"


def load_scontrol_show_node_output() -> str:
data = json.loads(FIXTURE_PATH.read_text())
return data["show_node"]
31 changes: 31 additions & 0 deletions tests/test_slurm_backend_job_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

import slurm_backend


def _build_squeue_output() -> str:
header = (
"JOBID USER STATE TIME LEFT PRIO NODES REASON GRES NAME CPU MEM PART "
"ACCOUNT QOS SUBMIT DEP"
)
rows = [
"100001 user01 RUNNING 00:10:00 00:50:00 100 1 cn01 gres/gpu:h100:4 train_a 64 256G all acct01 normal 2026-02-23T10:00:00 (null)",
"100002 user02 RUNNING 00:15:00 00:45:00 110 2 cn02 gres/gpu:h100:1 train_b 64 256G all acct02 normal 2026-02-23T10:05:00 (null)",
"100003 user03 RUNNING 00:05:00 00:55:00 120 1 cn03 gres/gpu:1 train_c 64 256G all acct03 normal 2026-02-23T10:10:00 (null)",
"100004 user04 RUNNING 00:20:00 00:40:00 130 4 cn04 gpu_total=64 train_d 64 256G all acct04 normal 2026-02-23T10:15:00 (null)",
"100005 user05 RUNNING 00:25:00 00:35:00 140 1 cn05 (null) train_e 64 256G all acct05 normal 2026-02-23T10:20:00 (null)",
]
return "\n".join([header, *rows])


def test_get_job_stats_parses_typed_and_untyped_gpu_counts(monkeypatch):
monkeypatch.setattr(slurm_backend, "run_slurm_command", lambda _cmd: _build_squeue_output())

jobs = slurm_backend.get_job_stats()
jobs_by_id = {job["id"]: job for job in jobs}

assert jobs_by_id["100001"]["gpu"] == "4"
assert jobs_by_id["100002"]["gpu"] == "2"
assert jobs_by_id["100003"]["gpu"] == "1"
assert jobs_by_id["100004"]["gpu"] == "64"
assert jobs_by_id["100005"]["gpu"] == "-"
39 changes: 39 additions & 0 deletions tests/test_slurm_backend_node_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

import slurm_backend

from fixture_loader import load_scontrol_show_node_output


def test_get_cluster_stats_parses_real_scontrol_fixture(monkeypatch):
show_node_output = load_scontrol_show_node_output()
monkeypatch.setattr(slurm_backend, "run_slurm_command", lambda _cmd: show_node_output)

nodes, theoretical, real = slurm_backend.get_cluster_stats()

assert len(nodes) == 35
assert theoretical == (2218, 3920, 230, 280)
assert real == (3920, 280)


def test_get_cluster_stats_parses_representative_gpu_nodes(monkeypatch):
show_node_output = load_scontrol_show_node_output()
monkeypatch.setattr(slurm_backend, "run_slurm_command", lambda _cmd: show_node_output)

nodes, _theoretical, _real = slurm_backend.get_cluster_stats()
by_name = {node["name"]: node for node in nodes}

assert by_name["cn01"]["g_t"] == 8
assert by_name["cn01"]["g_u"] == 6

assert by_name["cn02"]["g_t"] == 8
assert by_name["cn02"]["g_u"] == 4

assert by_name["cn06"]["g_t"] == 8
assert by_name["cn06"]["g_u"] == 3

assert by_name["cn20"]["g_t"] == 8
assert by_name["cn20"]["g_u"] == 1

assert by_name["cn13"]["g_u"] == 0
assert by_name["cn18"]["g_u"] == 0
Loading