slurmpast 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. slurmpast-0.2.0/LICENSE +21 -0
  2. slurmpast-0.2.0/PKG-INFO +256 -0
  3. slurmpast-0.2.0/README.md +215 -0
  4. slurmpast-0.2.0/pyproject.toml +123 -0
  5. slurmpast-0.2.0/setup.cfg +4 -0
  6. slurmpast-0.2.0/src/slurmpast/__init__.py +47 -0
  7. slurmpast-0.2.0/src/slurmpast/__main__.py +6 -0
  8. slurmpast-0.2.0/src/slurmpast/_version.py +1 -0
  9. slurmpast-0.2.0/src/slurmpast/cli.py +644 -0
  10. slurmpast-0.2.0/src/slurmpast/demo.py +311 -0
  11. slurmpast-0.2.0/src/slurmpast/diagnose.py +622 -0
  12. slurmpast-0.2.0/src/slurmpast/duration.py +278 -0
  13. slurmpast-0.2.0/src/slurmpast/index.py +471 -0
  14. slurmpast-0.2.0/src/slurmpast/logs.py +271 -0
  15. slurmpast-0.2.0/src/slurmpast/model.py +820 -0
  16. slurmpast-0.2.0/src/slurmpast/nodes.py +326 -0
  17. slurmpast-0.2.0/src/slurmpast/patterns.py +347 -0
  18. slurmpast-0.2.0/src/slurmpast/py.typed +0 -0
  19. slurmpast-0.2.0/src/slurmpast/render.py +898 -0
  20. slurmpast-0.2.0/src/slurmpast/report.py +587 -0
  21. slurmpast-0.2.0/src/slurmpast/sacct.py +643 -0
  22. slurmpast-0.2.0/src/slurmpast/site.py +163 -0
  23. slurmpast-0.2.0/src/slurmpast/sizing.py +365 -0
  24. slurmpast-0.2.0/src/slurmpast/theme.py +77 -0
  25. slurmpast-0.2.0/src/slurmpast/tui.py +1687 -0
  26. slurmpast-0.2.0/src/slurmpast.egg-info/PKG-INFO +256 -0
  27. slurmpast-0.2.0/src/slurmpast.egg-info/SOURCES.txt +47 -0
  28. slurmpast-0.2.0/src/slurmpast.egg-info/dependency_links.txt +1 -0
  29. slurmpast-0.2.0/src/slurmpast.egg-info/entry_points.txt +3 -0
  30. slurmpast-0.2.0/src/slurmpast.egg-info/requires.txt +11 -0
  31. slurmpast-0.2.0/src/slurmpast.egg-info/top_level.txt +1 -0
  32. slurmpast-0.2.0/tests/test_audit.py +392 -0
  33. slurmpast-0.2.0/tests/test_cli.py +155 -0
  34. slurmpast-0.2.0/tests/test_cpu_freq.py +117 -0
  35. slurmpast-0.2.0/tests/test_diagnose.py +272 -0
  36. slurmpast-0.2.0/tests/test_duration.py +140 -0
  37. slurmpast-0.2.0/tests/test_extraction.py +508 -0
  38. slurmpast-0.2.0/tests/test_grouping_names.py +90 -0
  39. slurmpast-0.2.0/tests/test_index.py +291 -0
  40. slurmpast-0.2.0/tests/test_layout.py +359 -0
  41. slurmpast-0.2.0/tests/test_nodes.py +247 -0
  42. slurmpast-0.2.0/tests/test_patterns.py +294 -0
  43. slurmpast-0.2.0/tests/test_portability.py +1040 -0
  44. slurmpast-0.2.0/tests/test_readability.py +774 -0
  45. slurmpast-0.2.0/tests/test_sacct.py +304 -0
  46. slurmpast-0.2.0/tests/test_sizing.py +193 -0
  47. slurmpast-0.2.0/tests/test_timespec.py +85 -0
  48. slurmpast-0.2.0/tests/test_tui.py +703 -0
  49. slurmpast-0.2.0/tests/test_ui_usability.py +636 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 slurmpost contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: slurmpast
3
+ Version: 0.2.0
4
+ Summary: Why did your Slurm jobs fail? A post-mortem dashboard for finished jobs.
5
+ Author-email: Youzhi Yu <yuyouzhi666@icloud.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/PursuitOfDataScience/slurmpast
8
+ Project-URL: Repository, https://github.com/PursuitOfDataScience/slurmpast.git
9
+ Project-URL: Documentation, https://github.com/PursuitOfDataScience/slurmpast#readme
10
+ Project-URL: Issues, https://github.com/PursuitOfDataScience/slurmpast/issues
11
+ Keywords: slurm,hpc,sacct,postmortem,job-failure,tui,seff
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: System :: Distributed Computing
24
+ Classifier: Topic :: System :: Monitoring
25
+ Classifier: Topic :: Utilities
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: textual<9,>=0.86
31
+ Requires-Dist: rich>=13.3.3
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=7; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4; extra == "dev"
36
+ Requires-Dist: ruff<0.17,>=0.15; extra == "dev"
37
+ Requires-Dist: mypy<3,>=2.3; extra == "dev"
38
+ Requires-Dist: build>=1.0; extra == "dev"
39
+ Requires-Dist: twine>=5.0; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ <h1 align="center">slurmpast</h1>
43
+
44
+ <p align="center">
45
+ <strong>How your finished Slurm jobs actually ran — and what the next one should ask for.</strong>
46
+ </p>
47
+
48
+ <p align="center">
49
+ <a href="https://github.com/PursuitOfDataScience/slurmpast/actions/workflows/ci.yml"><img src="https://github.com/PursuitOfDataScience/slurmpast/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
50
+ <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
51
+ <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
52
+ <img src="https://img.shields.io/badge/tests-791-brightgreen.svg" alt="791 tests">
53
+ </p>
54
+
55
+ <p align="center">
56
+ <img src="assets/screenshot-overview.svg" width="900" alt="slurmpast overview: finished jobs rolled into workloads, ranked by resource use.">
57
+ </p>
58
+
59
+ **Before** the run, [`slurmate`](https://github.com/PursuitOfDataScience/slurmate)
60
+ builds the request. **During** it,
61
+ [`slurmwatch`](https://github.com/PursuitOfDataScience/slurmwatch) watches.
62
+ **After** it, `slurmpast` tells you what happened and what to change.
63
+
64
+ ```bash
65
+ pip install slurmpast
66
+ ```
67
+
68
+ ```bash
69
+ slurmpast # dashboard, last 7 days
70
+ slurmpast --sizing # what to request next time, per workload
71
+ slurmpast 51170455 # one job, every field Slurm recorded
72
+ sp # short alias
73
+ ```
74
+
75
+ Keys: `enter` open · `q` back · digits jump to a row · `/` search · `f` filter ·
76
+ `s` sort · `n` nodes · `p` patterns · `y` copy · `?` help.
77
+ No Slurm to hand? `slurmpast --demo`.
78
+
79
+ ---
80
+
81
+ ## What to request next time
82
+
83
+ The reason to read finished jobs. Over-requesting narrows which nodes can host a
84
+ job and reserves capacity nobody else can use; under-requesting kills the run.
85
+ Your own history settles both.
86
+
87
+ ```
88
+ argonne35-pretrain test · 101 runs
89
+ --time raise to 10:00:00 (requested 8h05m, observed 7h58m03s at p95)
90
+ p95 of 90 completed runs is 7h58m03s (longest 7h58m23s); +25% headroom.
91
+ --mem already about right
92
+ --cpus-per-task already about right
93
+ #SBATCH --time=10:00:00
94
+ ```
95
+
96
+ That workload was running on a **seven-minute margin**. Four rules keep the
97
+ advice honest:
98
+
99
+ - A **TIMEOUT never sizes walltime down** — its elapsed is truncated at the
100
+ limit, so it bounds the true runtime only from below.
101
+ - A **hung run is not evidence of needing more time.** One real workload hit a
102
+ 30-minute wall 99 times on a median of 0.56 CPU-seconds; a longer limit buys a
103
+ longer hang.
104
+ - An **OOM kill outranks MaxRSS.** It is an event, not a sample.
105
+ - Below three usable runs it says **"not enough evidence"** rather than guessing.
106
+
107
+ ## One job
108
+
109
+ Same row idiom as `slurmwatch`, so a job you watched running looks like the same
110
+ object afterwards. The marker carries the resource's identity, the bar and the
111
+ number carry the magnitude, and the verdict lives in the findings below.
112
+
113
+ ```
114
+ ● TIME ████████████████▉░ 94.0% · 01:52:49 of the 02:00:00 limit
115
+ ● CPU █████████████▎░░░░ 73.5% · 2.9 of 4 cores busy
116
+ ● MEM ████████████████▋░ 92.3% · 184.6 GiB of the 200.0 GiB limit
117
+ ```
118
+
119
+ Three rows, because a gauge needs a ceiling to be a fraction of. Kernel share,
120
+ disk rate and GPU count have none, so they are printed as numbers below rather
121
+ than as bars that can never fill — an unfillable `░░░░` reads as a measured zero.
122
+
123
+ Below that: every field Slurm recorded, then the findings.
124
+
125
+ ```
126
+ gpu
127
+ devices 3 gpu-hours 5.6
128
+ utilization 87.4%
129
+ device memory 35.4 GiB peak
130
+ ```
131
+
132
+ That `utilization` is real wherever `gres.conf` sets `AutoDetect=nvml`. Where the
133
+ cluster does not gather it, the row says so and names the setting instead of
134
+ printing a zero.
135
+
136
+ ## Why the numbers differ from `seff`
137
+
138
+ Seven traps, each found on a real record, each with a regression test naming the
139
+ job id:
140
+
141
+ | Trap | Reality |
142
+ |---|---|
143
+ | `ReqMem` is `0n` on **2,130 of 6,574** jobs | the real ceiling is in `AllocTRES` |
144
+ | `AllocTRES` `mem=` is the **allocation total** | `--mem` and the cgroup are per *node*; a 2-node `--mem=8G` job records `mem=16G`, so dividing MaxRSS by it understates memory by the node count |
145
+ | `MaxRSS` reports **51.25 GiB against a 40 GiB limit** that OOM-killed | under `jobacct_gather/linux` it sums RSS across the process tree, double-counting shared pages — so the caveat is read from your `JobAcctGatherType`, not assumed |
146
+ | `MaxRSS` differs **4000×** between steps of one job | take the max, not a step |
147
+ | `TotalCPU` exists **only on steps** | read it from `.batch` |
148
+ | `State=RUNNING, End=Unknown` months after death | `Elapsed` becomes *now − start*; one record was 65% of a GPU-hour total |
149
+ | `sacct --state=X` returns **zero rows** without `-E` | always pass an end time |
150
+
151
+ A value that cannot be read prints `n/a`, never `0`.
152
+
153
+ ## Any Slurm cluster
154
+
155
+ Developed on one cluster, but nothing here is calibrated to it. Every place the
156
+ scheduler differs between sites is negotiated rather than assumed, and each of
157
+ these is a regression test in `tests/test_portability.py`:
158
+
159
+ | What varies | What it does about it |
160
+ |---|---|
161
+ | **Field names change.** Slurm 23.02 renamed `Reserved` → `Planned` | asks for whichever spelling your `sacct` accepts and reads it back under one name. Treating it as merely *optional* silently dropped queue wait on every cluster from 23.02 on |
162
+ | **`SLURM_TIME_FORMAT`** rewrites every timestamp | pinned to `standard` for the child process. A site exporting `relative` turns `2026-04-29T14:55:48` into `29 Apr 14:55`, which stops dates parsing and makes the chronological sort alphabetical on a month name |
163
+ | **`--constraint="v100\|a100"`** puts a pipe in a column, and `--parsable2` does not escape it | asks for an ASCII unit separator via `--delimiter` (in sacct since 17.11), falling back if absent. A pipe in field 32 shifted every memory, CPU and disk column after it |
164
+ | **Hostlists** can be `unit[0-31]rack[0-41]`, `node[0001-0010]-int`, `cn_[01-02]` | full expansion, differential-tested against `scontrol show hostnames`. The multi-range form used to yield `unit0rack[0-2]` *as a node name*, hiding every node in such an allocation |
165
+ | **GPUs** appear as `gres/gpu=4`, `gres/gpu:a100=4`, or pre-20.11 `AllocGRES=gpu:4` | all three read; typed entries summed |
166
+ | **`gres/gpuutil`** is gathered wherever `gres.conf` sets `AutoDetect=nvml` | real GPU utilization and peak HBM where recorded — and the idle-GPU finding becomes a measurement instead of an inference from host CPU |
167
+ | **`JobAcctGatherType`** decides what MaxRSS *is* | `jobacct_gather/cgroup` gives a genuine high-water mark, so the "double-counts shared pages" warning is withheld there rather than repeated |
168
+ | **`StdOut`/`StdErr`** exist from Slurm 21.08 | used, with `%j`/`%A`/`%a`/`%x`/`%N` expanded, before falling back to guessing a filename |
169
+ | **`--me`** needs Slurm 20.02 | falls back to `-u <you>`, not to an unfiltered `squeue` over the whole cluster |
170
+ | **Accounting may be off**, or `sacct` absent | a one-line explanation and exit 2, never a traceback; the query has a timeout so an unreachable `slurmdbd` cannot hang the dashboard |
171
+
172
+ `slurmpast --demo` pins a synthetic cluster config too, so the demo looks the
173
+ same on a login node and on a laptop.
174
+
175
+ ## Built for a real history
176
+
177
+ 6,581 jobs in seven months. A flat list of that is not an interface, so the
178
+ landing screen is a **workload rollup ranked by resource use** — a 5-run group
179
+ that cost 400 GPU-hours outranks 400 two-second probes. The top 20 rows carry
180
+ 93.4% of it, and what is below the fold is stated rather than dropped.
181
+
182
+ Job names encode parameters, so raw names barely group: 1,624 distinct names roll
183
+ up to 1,687 groups. Collapsing digit runs (`s1e20`, `s2e47` → `s#e#`) folds that
184
+ to 592, and a `NAMES` column says how many real names a pattern covers.
185
+
186
+ No cache: the full seven-month query is 1.54s and the index builds in 0.04s,
187
+ which does not justify a database. Logs are read only when you open a job.
188
+
189
+ ## Everything it extracts
190
+
191
+ 85 `sacct` fields — every one of the 107 this Slurm offers that carries a
192
+ distinct measurement, with pure redundancy (`DBIndex`, `BlockID`, duplicate
193
+ spellings of the same TRES) left out. The wide query costs 2.26s over seven
194
+ months against 1.38s for a minimal 27, so nobody has to re-run it because the
195
+ number they wanted was never collected.
196
+
197
+ | group | captured |
198
+ |---|---|
199
+ | **cpu** | total, **user vs kernel split**, utilization, frequency, per-task min/average, slowest task with its node |
200
+ | **memory** | ceiling **per node and per allocation**, peak with the node and task that hit it, average, task imbalance, virtual size, page faults |
201
+ | **filesystem** | **read and write separately**, rate |
202
+ | **gpu** | devices, GPU-hours, and **utilization and peak HBM where the site gathers them** |
203
+ | **timing** | submit/start/end, elapsed, limit, queue wait, **backfill vs main scheduler**, priority |
204
+ | **outcome** | state, exit code and signal, worst step exit, reason |
205
+ | **provenance** | work directory, recorded stdout/stderr paths, **the submit line itself** |
206
+ | per step | all of the above, for `.batch`, `.extern` and each srun step |
207
+
208
+ Two were entirely invisible with a narrow `--format`: **disk writes** (one run
209
+ read 112 GB and wrote 139 GB) and the **user/kernel CPU split** (791 of 6,582
210
+ jobs exceed 30% kernel time — syscall overhead no other tool surfaces).
211
+
212
+ `--json` emits all of it — 174 values per job. Nothing is captured and then hidden; a test fails if a field is read but never surfaced.
213
+
214
+ ## Also finds
215
+
216
+ **Repeated failure.** `seff` describes one job; `slurmwatch` describes one live
217
+ run. Neither can say *"you submitted this 115 times and it died 99 times."*
218
+
219
+ **Hand-searched memory.** One real series walked `48G → 32G → 32G → 17G → 12G →
220
+ 12G → 14G → 16G → 18G`, then succeeded at 32G — a value that had already OOM'd,
221
+ proving `--mem` was never the variable.
222
+
223
+ **Nodes that eat jobs**, controlled for workload, with Wilson intervals and a
224
+ ready-to-paste `--exclude`. Uncontrolled, one node looked 25% bad almost entirely
225
+ because a buggy campaign landed there — so a node is only called `worse` when its
226
+ interval clears the baseline.
227
+
228
+ ## Notes
229
+
230
+ Selecting text just works: mouse capture is off by default, so your terminal
231
+ handles selection as it does anywhere else. `M` hands the mouse to the app;
232
+ `y`/`Y` copy via OSC 52 and also write `~/.cache/slurmpast/clip.txt`.
233
+
234
+ The analysis modules import no third-party package and work as a library:
235
+
236
+ ```python
237
+ from slurmpast import History, Sacct
238
+ from slurmpast.sizing import recommend
239
+
240
+ history = History(Sacct().history(user="you", since="now-30days"))
241
+ for advice in recommend(history.groups[0].jobs):
242
+ print(advice.flag, advice.verdict, advice.suggestion)
243
+ ```
244
+
245
+ Developed against Slurm 20.11.8 and Textual 0.89–8.2; verified against the Slurm
246
+ documentation and release notes from 17.11 to 26.05, and against `scontrol show
247
+ hostnames` for node-name expansion. CI covers Python 3.10–3.13, both Textual
248
+ ends, and a no-Slurm machine.
249
+
250
+ What is *measured* is portable; what is *judged* is not. The thresholds — when a
251
+ CPU share is low, when kernel time is heavy, when a node counts as worse — are
252
+ heuristics from one cluster's history. They are conservative and each one is a
253
+ named constant, so recalibrating means editing a number rather than the logic.
254
+ `--nodes` is the piece most likely to need it.
255
+
256
+ MIT.
@@ -0,0 +1,215 @@
1
+ <h1 align="center">slurmpast</h1>
2
+
3
+ <p align="center">
4
+ <strong>How your finished Slurm jobs actually ran — and what the next one should ask for.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/PursuitOfDataScience/slurmpast/actions/workflows/ci.yml"><img src="https://github.com/PursuitOfDataScience/slurmpast/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
9
+ <img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
10
+ <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
11
+ <img src="https://img.shields.io/badge/tests-791-brightgreen.svg" alt="791 tests">
12
+ </p>
13
+
14
+ <p align="center">
15
+ <img src="assets/screenshot-overview.svg" width="900" alt="slurmpast overview: finished jobs rolled into workloads, ranked by resource use.">
16
+ </p>
17
+
18
+ **Before** the run, [`slurmate`](https://github.com/PursuitOfDataScience/slurmate)
19
+ builds the request. **During** it,
20
+ [`slurmwatch`](https://github.com/PursuitOfDataScience/slurmwatch) watches.
21
+ **After** it, `slurmpast` tells you what happened and what to change.
22
+
23
+ ```bash
24
+ pip install slurmpast
25
+ ```
26
+
27
+ ```bash
28
+ slurmpast # dashboard, last 7 days
29
+ slurmpast --sizing # what to request next time, per workload
30
+ slurmpast 51170455 # one job, every field Slurm recorded
31
+ sp # short alias
32
+ ```
33
+
34
+ Keys: `enter` open · `q` back · digits jump to a row · `/` search · `f` filter ·
35
+ `s` sort · `n` nodes · `p` patterns · `y` copy · `?` help.
36
+ No Slurm to hand? `slurmpast --demo`.
37
+
38
+ ---
39
+
40
+ ## What to request next time
41
+
42
+ The reason to read finished jobs. Over-requesting narrows which nodes can host a
43
+ job and reserves capacity nobody else can use; under-requesting kills the run.
44
+ Your own history settles both.
45
+
46
+ ```
47
+ argonne35-pretrain test · 101 runs
48
+ --time raise to 10:00:00 (requested 8h05m, observed 7h58m03s at p95)
49
+ p95 of 90 completed runs is 7h58m03s (longest 7h58m23s); +25% headroom.
50
+ --mem already about right
51
+ --cpus-per-task already about right
52
+ #SBATCH --time=10:00:00
53
+ ```
54
+
55
+ That workload was running on a **seven-minute margin**. Four rules keep the
56
+ advice honest:
57
+
58
+ - A **TIMEOUT never sizes walltime down** — its elapsed is truncated at the
59
+ limit, so it bounds the true runtime only from below.
60
+ - A **hung run is not evidence of needing more time.** One real workload hit a
61
+ 30-minute wall 99 times on a median of 0.56 CPU-seconds; a longer limit buys a
62
+ longer hang.
63
+ - An **OOM kill outranks MaxRSS.** It is an event, not a sample.
64
+ - Below three usable runs it says **"not enough evidence"** rather than guessing.
65
+
66
+ ## One job
67
+
68
+ Same row idiom as `slurmwatch`, so a job you watched running looks like the same
69
+ object afterwards. The marker carries the resource's identity, the bar and the
70
+ number carry the magnitude, and the verdict lives in the findings below.
71
+
72
+ ```
73
+ ● TIME ████████████████▉░ 94.0% · 01:52:49 of the 02:00:00 limit
74
+ ● CPU █████████████▎░░░░ 73.5% · 2.9 of 4 cores busy
75
+ ● MEM ████████████████▋░ 92.3% · 184.6 GiB of the 200.0 GiB limit
76
+ ```
77
+
78
+ Three rows, because a gauge needs a ceiling to be a fraction of. Kernel share,
79
+ disk rate and GPU count have none, so they are printed as numbers below rather
80
+ than as bars that can never fill — an unfillable `░░░░` reads as a measured zero.
81
+
82
+ Below that: every field Slurm recorded, then the findings.
83
+
84
+ ```
85
+ gpu
86
+ devices 3 gpu-hours 5.6
87
+ utilization 87.4%
88
+ device memory 35.4 GiB peak
89
+ ```
90
+
91
+ That `utilization` is real wherever `gres.conf` sets `AutoDetect=nvml`. Where the
92
+ cluster does not gather it, the row says so and names the setting instead of
93
+ printing a zero.
94
+
95
+ ## Why the numbers differ from `seff`
96
+
97
+ Seven traps, each found on a real record, each with a regression test naming the
98
+ job id:
99
+
100
+ | Trap | Reality |
101
+ |---|---|
102
+ | `ReqMem` is `0n` on **2,130 of 6,574** jobs | the real ceiling is in `AllocTRES` |
103
+ | `AllocTRES` `mem=` is the **allocation total** | `--mem` and the cgroup are per *node*; a 2-node `--mem=8G` job records `mem=16G`, so dividing MaxRSS by it understates memory by the node count |
104
+ | `MaxRSS` reports **51.25 GiB against a 40 GiB limit** that OOM-killed | under `jobacct_gather/linux` it sums RSS across the process tree, double-counting shared pages — so the caveat is read from your `JobAcctGatherType`, not assumed |
105
+ | `MaxRSS` differs **4000×** between steps of one job | take the max, not a step |
106
+ | `TotalCPU` exists **only on steps** | read it from `.batch` |
107
+ | `State=RUNNING, End=Unknown` months after death | `Elapsed` becomes *now − start*; one record was 65% of a GPU-hour total |
108
+ | `sacct --state=X` returns **zero rows** without `-E` | always pass an end time |
109
+
110
+ A value that cannot be read prints `n/a`, never `0`.
111
+
112
+ ## Any Slurm cluster
113
+
114
+ Developed on one cluster, but nothing here is calibrated to it. Every place the
115
+ scheduler differs between sites is negotiated rather than assumed, and each of
116
+ these is a regression test in `tests/test_portability.py`:
117
+
118
+ | What varies | What it does about it |
119
+ |---|---|
120
+ | **Field names change.** Slurm 23.02 renamed `Reserved` → `Planned` | asks for whichever spelling your `sacct` accepts and reads it back under one name. Treating it as merely *optional* silently dropped queue wait on every cluster from 23.02 on |
121
+ | **`SLURM_TIME_FORMAT`** rewrites every timestamp | pinned to `standard` for the child process. A site exporting `relative` turns `2026-04-29T14:55:48` into `29 Apr 14:55`, which stops dates parsing and makes the chronological sort alphabetical on a month name |
122
+ | **`--constraint="v100\|a100"`** puts a pipe in a column, and `--parsable2` does not escape it | asks for an ASCII unit separator via `--delimiter` (in sacct since 17.11), falling back if absent. A pipe in field 32 shifted every memory, CPU and disk column after it |
123
+ | **Hostlists** can be `unit[0-31]rack[0-41]`, `node[0001-0010]-int`, `cn_[01-02]` | full expansion, differential-tested against `scontrol show hostnames`. The multi-range form used to yield `unit0rack[0-2]` *as a node name*, hiding every node in such an allocation |
124
+ | **GPUs** appear as `gres/gpu=4`, `gres/gpu:a100=4`, or pre-20.11 `AllocGRES=gpu:4` | all three read; typed entries summed |
125
+ | **`gres/gpuutil`** is gathered wherever `gres.conf` sets `AutoDetect=nvml` | real GPU utilization and peak HBM where recorded — and the idle-GPU finding becomes a measurement instead of an inference from host CPU |
126
+ | **`JobAcctGatherType`** decides what MaxRSS *is* | `jobacct_gather/cgroup` gives a genuine high-water mark, so the "double-counts shared pages" warning is withheld there rather than repeated |
127
+ | **`StdOut`/`StdErr`** exist from Slurm 21.08 | used, with `%j`/`%A`/`%a`/`%x`/`%N` expanded, before falling back to guessing a filename |
128
+ | **`--me`** needs Slurm 20.02 | falls back to `-u <you>`, not to an unfiltered `squeue` over the whole cluster |
129
+ | **Accounting may be off**, or `sacct` absent | a one-line explanation and exit 2, never a traceback; the query has a timeout so an unreachable `slurmdbd` cannot hang the dashboard |
130
+
131
+ `slurmpast --demo` pins a synthetic cluster config too, so the demo looks the
132
+ same on a login node and on a laptop.
133
+
134
+ ## Built for a real history
135
+
136
+ 6,581 jobs in seven months. A flat list of that is not an interface, so the
137
+ landing screen is a **workload rollup ranked by resource use** — a 5-run group
138
+ that cost 400 GPU-hours outranks 400 two-second probes. The top 20 rows carry
139
+ 93.4% of it, and what is below the fold is stated rather than dropped.
140
+
141
+ Job names encode parameters, so raw names barely group: 1,624 distinct names roll
142
+ up to 1,687 groups. Collapsing digit runs (`s1e20`, `s2e47` → `s#e#`) folds that
143
+ to 592, and a `NAMES` column says how many real names a pattern covers.
144
+
145
+ No cache: the full seven-month query is 1.54s and the index builds in 0.04s,
146
+ which does not justify a database. Logs are read only when you open a job.
147
+
148
+ ## Everything it extracts
149
+
150
+ 85 `sacct` fields — every one of the 107 this Slurm offers that carries a
151
+ distinct measurement, with pure redundancy (`DBIndex`, `BlockID`, duplicate
152
+ spellings of the same TRES) left out. The wide query costs 2.26s over seven
153
+ months against 1.38s for a minimal 27, so nobody has to re-run it because the
154
+ number they wanted was never collected.
155
+
156
+ | group | captured |
157
+ |---|---|
158
+ | **cpu** | total, **user vs kernel split**, utilization, frequency, per-task min/average, slowest task with its node |
159
+ | **memory** | ceiling **per node and per allocation**, peak with the node and task that hit it, average, task imbalance, virtual size, page faults |
160
+ | **filesystem** | **read and write separately**, rate |
161
+ | **gpu** | devices, GPU-hours, and **utilization and peak HBM where the site gathers them** |
162
+ | **timing** | submit/start/end, elapsed, limit, queue wait, **backfill vs main scheduler**, priority |
163
+ | **outcome** | state, exit code and signal, worst step exit, reason |
164
+ | **provenance** | work directory, recorded stdout/stderr paths, **the submit line itself** |
165
+ | per step | all of the above, for `.batch`, `.extern` and each srun step |
166
+
167
+ Two were entirely invisible with a narrow `--format`: **disk writes** (one run
168
+ read 112 GB and wrote 139 GB) and the **user/kernel CPU split** (791 of 6,582
169
+ jobs exceed 30% kernel time — syscall overhead no other tool surfaces).
170
+
171
+ `--json` emits all of it — 174 values per job. Nothing is captured and then hidden; a test fails if a field is read but never surfaced.
172
+
173
+ ## Also finds
174
+
175
+ **Repeated failure.** `seff` describes one job; `slurmwatch` describes one live
176
+ run. Neither can say *"you submitted this 115 times and it died 99 times."*
177
+
178
+ **Hand-searched memory.** One real series walked `48G → 32G → 32G → 17G → 12G →
179
+ 12G → 14G → 16G → 18G`, then succeeded at 32G — a value that had already OOM'd,
180
+ proving `--mem` was never the variable.
181
+
182
+ **Nodes that eat jobs**, controlled for workload, with Wilson intervals and a
183
+ ready-to-paste `--exclude`. Uncontrolled, one node looked 25% bad almost entirely
184
+ because a buggy campaign landed there — so a node is only called `worse` when its
185
+ interval clears the baseline.
186
+
187
+ ## Notes
188
+
189
+ Selecting text just works: mouse capture is off by default, so your terminal
190
+ handles selection as it does anywhere else. `M` hands the mouse to the app;
191
+ `y`/`Y` copy via OSC 52 and also write `~/.cache/slurmpast/clip.txt`.
192
+
193
+ The analysis modules import no third-party package and work as a library:
194
+
195
+ ```python
196
+ from slurmpast import History, Sacct
197
+ from slurmpast.sizing import recommend
198
+
199
+ history = History(Sacct().history(user="you", since="now-30days"))
200
+ for advice in recommend(history.groups[0].jobs):
201
+ print(advice.flag, advice.verdict, advice.suggestion)
202
+ ```
203
+
204
+ Developed against Slurm 20.11.8 and Textual 0.89–8.2; verified against the Slurm
205
+ documentation and release notes from 17.11 to 26.05, and against `scontrol show
206
+ hostnames` for node-name expansion. CI covers Python 3.10–3.13, both Textual
207
+ ends, and a no-Slurm machine.
208
+
209
+ What is *measured* is portable; what is *judged* is not. The thresholds — when a
210
+ CPU share is low, when kernel time is heavy, when a node counts as worse — are
211
+ heuristics from one cluster's history. They are conservative and each one is a
212
+ named constant, so recalibrating means editing a number rather than the logic.
213
+ `--nodes` is the piece most likely to need it.
214
+
215
+ MIT.
@@ -0,0 +1,123 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "slurmpast"
7
+ dynamic = ["version"]
8
+ description = "Why did your Slurm jobs fail? A post-mortem dashboard for finished jobs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Youzhi Yu", email = "yuyouzhi666@icloud.com" }]
13
+ keywords = ["slurm", "hpc", "sacct", "postmortem", "job-failure", "tui", "seff"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Science/Research",
18
+ "Natural Language :: English",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering",
26
+ "Topic :: System :: Distributed Computing",
27
+ "Topic :: System :: Monitoring",
28
+ "Topic :: Utilities",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = [
32
+ # Same floor as slurmwatch: >=0.86 is where the theme system landed, which the
33
+ # dashboard uses to register its own theme, and it covers DataTable/Header/
34
+ # Footer/Input. Matching the floor means the two tools resolve to one Textual
35
+ # in a shared environment instead of fighting over it.
36
+ "textual>=0.86,<9",
37
+ # Imported directly by render.py and tui.py (`rich.text.Text`), and render.py
38
+ # is on the plain-text path too, so `--plain` needs it as much as the
39
+ # dashboard does. It resolved anyway as a transitive dependency of textual,
40
+ # which is exactly why it was easy to miss -- a package you import belongs in
41
+ # your own dependency list, not borrowed from someone else's. Floor matches
42
+ # what textual itself requires.
43
+ "rich>=13.3.3",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/PursuitOfDataScience/slurmpast"
48
+ Repository = "https://github.com/PursuitOfDataScience/slurmpast.git"
49
+ Documentation = "https://github.com/PursuitOfDataScience/slurmpast#readme"
50
+ Issues = "https://github.com/PursuitOfDataScience/slurmpast/issues"
51
+
52
+ [project.scripts]
53
+ slurmpast = "slurmpast.cli:main"
54
+ # "sp" is a short alias for the same entry point -- same behaviour, less typing.
55
+ # Parallels slurmwatch's "sw"; verified free on PATH.
56
+ sp = "slurmpast.cli:main"
57
+
58
+ [project.optional-dependencies]
59
+ dev = [
60
+ "pytest>=7",
61
+ "pytest-asyncio>=0.23",
62
+ "pytest-cov>=4",
63
+ # Bounded on purpose: an unpinned linter makes CI a moving target, and a
64
+ # minor ruff release changing its rule defaults is exactly what broke the
65
+ # first CI run here.
66
+ "ruff>=0.15,<0.17",
67
+ "mypy>=2.3,<3", # >=2.3: 2.2 added the MRO override check CI tripped on
68
+ "build>=1.0",
69
+ "twine>=5.0",
70
+ ]
71
+
72
+ [tool.setuptools.dynamic]
73
+ version = { attr = "slurmpast._version.__version__" }
74
+
75
+ [tool.setuptools.packages.find]
76
+ where = ["src"]
77
+ include = ["slurmpast*"]
78
+
79
+ [tool.setuptools.package-data]
80
+ slurmpast = ["py.typed"]
81
+
82
+ [tool.ruff]
83
+ line-length = 100
84
+ target-version = "py310"
85
+
86
+ [tool.ruff.lint]
87
+ # Pinned explicitly, matching slurmwatch. Without a `select` the gate is whatever
88
+ # the installed ruff defaults to -- which is how CI failed on a commit that was
89
+ # clean locally: 0.15 and 0.16 disagree about the default set, and 0.16 surfaced
90
+ # 206 findings the local run never saw. An explicit list makes the gate mean the
91
+ # same thing on every machine.
92
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "ARG", "C4", "T20"]
93
+ # UP031 (%-format -> f-string) fires on 206 sites. Ruff auto-converts 113 of them
94
+ # with all tests still passing, so the migration is safe -- but it also introduces
95
+ # 28 line-length violations and leaves 93 sites for hand conversion. That is a
96
+ # refactor, not a lint fix, and a half-migrated file is worse than a consistent
97
+ # one. Recorded here rather than silently dropped.
98
+ ignore = ["UP031"]
99
+
100
+ [tool.ruff.lint.per-file-ignores]
101
+ # A CLI writes to stdout; that is the point of it.
102
+ "src/slurmpast/cli.py" = ["T201"]
103
+ # Tests deliberately accept arguments they do not use (stub runners that must
104
+ # match a real signature) and build fixtures with sacct's own CamelCase names.
105
+ "tests/*" = ["ARG", "N802", "N803", "N806", "N815"]
106
+
107
+ [tool.ruff.format]
108
+ quote-style = "double"
109
+
110
+ [tool.mypy]
111
+ python_version = "3.10"
112
+ ignore_missing_imports = true
113
+ warn_redundant_casts = true
114
+ warn_unreachable = true
115
+ # NOT strict = true, which is what slurmwatch runs. Under --strict this codebase
116
+ # reports 495 findings, almost all missing parameter/return annotations across
117
+ # ~4,700 lines. That is worth doing, but it is an annotation project rather than
118
+ # part of setting up CI, so the gate is set where it currently passes clean and
119
+ # the gap is recorded here rather than silently skipped.
120
+
121
+ [tool.pytest.ini_options]
122
+ testpaths = ["tests"]
123
+ asyncio_mode = "strict"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ """slurmpast — why did your Slurm jobs fail?
2
+
3
+ A post-mortem for finished jobs. ``slurmate`` builds the request, ``slurmwatch``
4
+ watches the run, this reads the wreckage.
5
+
6
+ The analysis modules (``sacct``, ``diagnose``, ``patterns``, ``nodes``,
7
+ ``index``, ``site``) import no third-party package, so they are usable as a
8
+ library on a login node without a UI framework. Textual is only pulled in by
9
+ ``tui``.
10
+
11
+ Nothing here is specific to one cluster. Field names, output delimiters and
12
+ timestamp formats are negotiated with the local ``sacct``; what a measurement
13
+ means where sites differ (``JobAcctGatherType``, whether GPUs reach accounting at
14
+ all) is read from ``scontrol show config`` by ``site``. See that module and the
15
+ traps listed in ``sacct``.
16
+ """
17
+
18
+ from ._version import __version__
19
+ from .diagnose import diagnose, looks_like_noop
20
+ from .index import GroupStats, History, build_groups
21
+ from .model import CRITICAL, INFO, WARNING, Finding, Job, Step, Verdict
22
+ from .sacct import Sacct, SacctError
23
+
24
+ # Only the type, deliberately. Re-exporting the `site()` accessor here would bind
25
+ # the name `slurmpast.site` to a function and shadow the submodule of the same
26
+ # name, so `slurmpast.site.reset_cache` would stop resolving. Import it as
27
+ # `from slurmpast.site import site`.
28
+ from .site import Site
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "Sacct",
33
+ "SacctError",
34
+ "Job",
35
+ "Step",
36
+ "Finding",
37
+ "Verdict",
38
+ "History",
39
+ "GroupStats",
40
+ "Site",
41
+ "build_groups",
42
+ "diagnose",
43
+ "looks_like_noop",
44
+ "CRITICAL",
45
+ "WARNING",
46
+ "INFO",
47
+ ]