shardguard 1.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonas Reiter
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,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: shardguard
3
+ Version: 1.0.0
4
+ Summary: Cap the GPU memory of PyTorch at the GPU shards of a Slurm job
5
+ Author: Jonas Reiter
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jonaslreiter/shardguard
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Topic :: System :: Distributed Computing
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # shardguard
17
+
18
+ Slurm can split a GPU into shards, but it does not limit the GPU memory of a job that holds shards, so the job can take memory that its co-tenants on the card were promised. shardguard caps the CUDA memory of PyTorch at the share of the GPU that the shards of the job stand for. Install it with
19
+
20
+ ```bash
21
+ pip install shardguard
22
+ ```
23
+
24
+ and import it in the entry point of your program, before anything initialises CUDA:
25
+
26
+ ```python
27
+ import shardguard
28
+ ```
29
+
30
+ Past the cap, PyTorch raises an out-of-memory error. Outside a Slurm job that holds shards, nothing happens.
31
+
32
+ ## How it works
33
+
34
+ The share is the number of shards of the job over the number of shards one GPU of the node holds, which shardguard reads from `scontrol show node`. Setting `SHARDGUARD_SHARDS_PER_GPU` gives that number instead. The cap is `per_process_memory_fraction` in `PYTORCH_ALLOC_CONF` and `PYTORCH_CUDA_ALLOC_CONF`, together with `expandable_segments`, which keeps the memory PyTorch reserves close to what it allocates.
35
+
36
+ The cap needs PyTorch 2.10 or later with its native allocator. PyTorch reads the setting when it initialises CUDA, so shardguard raises an error when it is imported after that. The cap applies to each process separately and does not cover the CUDA context, which takes a few hundred MB. shardguard also raises an error when the shards of the job are spread over several GPUs.
@@ -0,0 +1,21 @@
1
+ # shardguard
2
+
3
+ Slurm can split a GPU into shards, but it does not limit the GPU memory of a job that holds shards, so the job can take memory that its co-tenants on the card were promised. shardguard caps the CUDA memory of PyTorch at the share of the GPU that the shards of the job stand for. Install it with
4
+
5
+ ```bash
6
+ pip install shardguard
7
+ ```
8
+
9
+ and import it in the entry point of your program, before anything initialises CUDA:
10
+
11
+ ```python
12
+ import shardguard
13
+ ```
14
+
15
+ Past the cap, PyTorch raises an out-of-memory error. Outside a Slurm job that holds shards, nothing happens.
16
+
17
+ ## How it works
18
+
19
+ The share is the number of shards of the job over the number of shards one GPU of the node holds, which shardguard reads from `scontrol show node`. Setting `SHARDGUARD_SHARDS_PER_GPU` gives that number instead. The cap is `per_process_memory_fraction` in `PYTORCH_ALLOC_CONF` and `PYTORCH_CUDA_ALLOC_CONF`, together with `expandable_segments`, which keeps the memory PyTorch reserves close to what it allocates.
20
+
21
+ The cap needs PyTorch 2.10 or later with its native allocator. PyTorch reads the setting when it initialises CUDA, so shardguard raises an error when it is imported after that. The cap applies to each process separately and does not cover the CUDA context, which takes a few hundred MB. shardguard also raises an error when the shards of the job are spread over several GPUs.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shardguard"
7
+ version = "1.0.0"
8
+ description = "Cap the GPU memory of PyTorch at the GPU shards of a Slurm job"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Jonas Reiter" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Topic :: System :: Distributed Computing",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/jonaslreiter/shardguard"
22
+
23
+ [dependency-groups]
24
+ dev = ["pytest>=8"]
25
+
26
+ [tool.setuptools]
27
+ package-dir = { "" = "src" }
28
+ py-modules = ["shardguard"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+
33
+ [tool.black]
34
+ target-version = ["py310"]
35
+
36
+ [tool.isort]
37
+ profile = "black"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: shardguard
3
+ Version: 1.0.0
4
+ Summary: Cap the GPU memory of PyTorch at the GPU shards of a Slurm job
5
+ Author: Jonas Reiter
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jonaslreiter/shardguard
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Topic :: System :: Distributed Computing
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # shardguard
17
+
18
+ Slurm can split a GPU into shards, but it does not limit the GPU memory of a job that holds shards, so the job can take memory that its co-tenants on the card were promised. shardguard caps the CUDA memory of PyTorch at the share of the GPU that the shards of the job stand for. Install it with
19
+
20
+ ```bash
21
+ pip install shardguard
22
+ ```
23
+
24
+ and import it in the entry point of your program, before anything initialises CUDA:
25
+
26
+ ```python
27
+ import shardguard
28
+ ```
29
+
30
+ Past the cap, PyTorch raises an out-of-memory error. Outside a Slurm job that holds shards, nothing happens.
31
+
32
+ ## How it works
33
+
34
+ The share is the number of shards of the job over the number of shards one GPU of the node holds, which shardguard reads from `scontrol show node`. Setting `SHARDGUARD_SHARDS_PER_GPU` gives that number instead. The cap is `per_process_memory_fraction` in `PYTORCH_ALLOC_CONF` and `PYTORCH_CUDA_ALLOC_CONF`, together with `expandable_segments`, which keeps the memory PyTorch reserves close to what it allocates.
35
+
36
+ The cap needs PyTorch 2.10 or later with its native allocator. PyTorch reads the setting when it initialises CUDA, so shardguard raises an error when it is imported after that. The cap applies to each process separately and does not cover the CUDA context, which takes a few hundred MB. shardguard also raises an error when the shards of the job are spread over several GPUs.
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/shardguard.py
5
+ src/shardguard.egg-info/PKG-INFO
6
+ src/shardguard.egg-info/SOURCES.txt
7
+ src/shardguard.egg-info/dependency_links.txt
8
+ src/shardguard.egg-info/top_level.txt
9
+ tests/test_shardguard.py
@@ -0,0 +1 @@
1
+ shardguard
@@ -0,0 +1,131 @@
1
+ """Importing shardguard caps the CUDA memory of PyTorch at the share of a GPU that the GPU shards of the Slurm job stand for.
2
+
3
+ The cap is ``per_process_memory_fraction`` in the allocator configuration of PyTorch, set to the shards of the job over the shards one GPU of the node holds. ``expandable_segments`` keeps what PyTorch reserves close to what it allocates. PyTorch reads the configuration when it initialises CUDA, so shardguard must be imported before that, which it checks. Outside a job holding shards, it does nothing.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from importlib import metadata
14
+ from typing import MutableMapping
15
+
16
+ SHARDS_PER_GPU = "SHARDGUARD_SHARDS_PER_GPU"
17
+ # PyTorch reads the first of these that is set, and releases before 2.9 only the second.
18
+ ALLOC_CONF = ("PYTORCH_ALLOC_CONF", "PYTORCH_CUDA_ALLOC_CONF")
19
+ FRACTION = re.compile(r"per_process_memory_fraction:([0-9.]+)")
20
+ # The first release whose allocator configuration takes per_process_memory_fraction.
21
+ OLDEST_TORCH = (2, 10)
22
+
23
+
24
+ def shards_per_gpu(gres: str) -> float | None:
25
+ """Shards per GPU from the ``Gres`` of a node, e.g. 24 for ``gpu:A10:4,shard:A10:96``, or None if it lists no GPUs or no shards."""
26
+ counts = {"gpu": 0, "shard": 0}
27
+ for entry in re.sub(r"\([^)]*\)", "", gres).split(","):
28
+ fields = entry.split(":")
29
+ if fields[0] in counts and fields[-1].isdigit():
30
+ counts[fields[0]] += int(fields[-1])
31
+ if not counts["gpu"] or not counts["shard"]:
32
+ return None
33
+ return counts["shard"] / counts["gpu"]
34
+
35
+
36
+ def node_gres(node: str) -> str:
37
+ """The ``Gres`` that ``scontrol show node`` reports for `node`."""
38
+ output = subprocess.run(
39
+ ["scontrol", "show", "node", node],
40
+ capture_output=True,
41
+ text=True,
42
+ check=True,
43
+ timeout=60,
44
+ ).stdout
45
+ match = re.search(r"\bGres=(\S*)", output)
46
+ return match.group(1) if match else ""
47
+
48
+
49
+ def fraction(environ: MutableMapping[str, str] = os.environ) -> float | None:
50
+ """The share of one GPU that the shards of the job stand for, or None outside a job holding shards."""
51
+ shards = environ.get("SLURM_SHARDS_ON_NODE")
52
+ if not shards:
53
+ return None
54
+ if "," in environ.get("CUDA_VISIBLE_DEVICES", ""):
55
+ raise RuntimeError(
56
+ "shardguard: the shards of this job are on several GPUs, which one fraction per GPU cannot cap"
57
+ )
58
+ per_gpu = environ.get(SHARDS_PER_GPU)
59
+ if not per_gpu:
60
+ try:
61
+ per_gpu = shards_per_gpu(node_gres(environ["SLURMD_NODENAME"]))
62
+ except (KeyError, OSError, subprocess.SubprocessError):
63
+ per_gpu = None
64
+ if not per_gpu:
65
+ raise RuntimeError(
66
+ f"shardguard: cannot tell how many shards one GPU of this node holds, so set {SHARDS_PER_GPU}"
67
+ )
68
+ environ[SHARDS_PER_GPU] = f"{per_gpu:g}"
69
+ return min(1.0, int(shards) / float(per_gpu))
70
+
71
+
72
+ def _rounded(share: float) -> float:
73
+ """`share` rounded down to four decimals."""
74
+ return math.floor(share * 1e4) / 1e4
75
+
76
+
77
+ def with_cap(conf: str, share: float) -> str:
78
+ """An allocator configuration capped at `share`, with expandable segments unless it sets them itself. A lower cap it already has is kept."""
79
+ settings = [item for item in conf.split(",") if item]
80
+ capped = f"per_process_memory_fraction:{_rounded(share)}"
81
+ match = FRACTION.search(conf)
82
+ if match is None:
83
+ settings.append(capped)
84
+ elif float(match.group(1)) > share:
85
+ settings = [capped if FRACTION.fullmatch(item) else item for item in settings]
86
+ if not any(item.startswith("expandable_segments:") for item in settings):
87
+ settings.append("expandable_segments:True")
88
+ return ",".join(settings)
89
+
90
+
91
+ def torch_version() -> tuple[int, int] | None:
92
+ """The major and minor version of the installed PyTorch, or None if it is not installed."""
93
+ try:
94
+ version = metadata.version("torch")
95
+ except metadata.PackageNotFoundError:
96
+ return None
97
+ major, minor = re.match(r"(\d+)\.(\d+)", version).groups()
98
+ return int(major), int(minor)
99
+
100
+
101
+ def cap(environ: MutableMapping[str, str] = os.environ) -> float | None:
102
+ """Cap the CUDA memory of PyTorch at the shards of the job and return the share, or None outside a job holding shards."""
103
+ share = fraction(environ)
104
+ if share is None:
105
+ return None
106
+ version = torch_version()
107
+ if version is None or version < OLDEST_TORCH:
108
+ raise RuntimeError(
109
+ "shardguard: the cap needs PyTorch 2.10 or later, whose allocator configuration takes per_process_memory_fraction"
110
+ )
111
+ torch = sys.modules.get("torch")
112
+ if torch is not None and torch.cuda.is_initialized():
113
+ raise RuntimeError(
114
+ "shardguard: import it before CUDA is initialised, since PyTorch ignores the cap after that"
115
+ )
116
+ current = next((environ[name] for name in ALLOC_CONF if environ.get(name)), "")
117
+ if "cudaMallocAsync" in current:
118
+ raise RuntimeError(
119
+ "shardguard: the cudaMallocAsync backend ignores per_process_memory_fraction"
120
+ )
121
+ capped = with_cap(current, share)
122
+ for name in ALLOC_CONF:
123
+ environ[name] = capped
124
+ print(
125
+ f"shardguard: CUDA memory capped at {_rounded(share)} of a GPU, the share of {environ['SLURM_SHARDS_ON_NODE']} of its {environ[SHARDS_PER_GPU]} shards",
126
+ file=sys.stderr,
127
+ )
128
+ return share
129
+
130
+
131
+ cap()
@@ -0,0 +1,129 @@
1
+ import sys
2
+ import types
3
+
4
+ import pytest
5
+
6
+ import shardguard
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def torch_2_14(monkeypatch):
11
+ monkeypatch.setattr(shardguard, "torch_version", lambda: (2, 14))
12
+
13
+
14
+ @pytest.mark.parametrize(
15
+ "gres, expected",
16
+ [
17
+ ("gpu:A10:4,shard:A10:96", 24),
18
+ ("gpu:l40s:2(S:0-1),shard:l40s:96(S:0-1)", 48),
19
+ ("gpu:4", None),
20
+ ("(null)", None),
21
+ ],
22
+ )
23
+ def test_shards_per_gpu_is_read_off_the_gres(gres, expected):
24
+ assert shardguard.shards_per_gpu(gres) == expected
25
+
26
+
27
+ def test_outside_a_shard_job_nothing_is_capped():
28
+ environ = {"SLURM_JOB_ID": "1"}
29
+ assert shardguard.cap(environ) is None
30
+ assert environ == {"SLURM_JOB_ID": "1"}
31
+
32
+
33
+ def test_the_cap_is_the_jobs_share_of_a_gpu():
34
+ environ = {"SLURM_SHARDS_ON_NODE": "6", shardguard.SHARDS_PER_GPU: "24"}
35
+ assert shardguard.cap(environ) == 0.25
36
+ for name in shardguard.ALLOC_CONF:
37
+ assert (
38
+ environ[name] == "per_process_memory_fraction:0.25,expandable_segments:True"
39
+ )
40
+
41
+
42
+ def test_the_shards_per_gpu_come_from_the_node_once(monkeypatch):
43
+ calls = []
44
+ monkeypatch.setattr(
45
+ shardguard,
46
+ "node_gres",
47
+ lambda node: calls.append(node) or "gpu:A10:4,shard:A10:96",
48
+ )
49
+ environ = {"SLURM_SHARDS_ON_NODE": "12", "SLURMD_NODENAME": "cn-1"}
50
+ assert shardguard.fraction(environ) == 0.5
51
+ assert shardguard.fraction(environ) == 0.5
52
+ assert calls == ["cn-1"]
53
+
54
+
55
+ @pytest.mark.parametrize("gres", ["gpu:A10:4", OSError, KeyError])
56
+ def test_a_node_that_cannot_say_its_shards_is_refused(monkeypatch, gres):
57
+ def node_gres(node):
58
+ if isinstance(gres, str):
59
+ return gres
60
+ raise gres
61
+
62
+ monkeypatch.setattr(shardguard, "node_gres", node_gres)
63
+ with pytest.raises(RuntimeError, match=shardguard.SHARDS_PER_GPU):
64
+ shardguard.fraction({"SLURM_SHARDS_ON_NODE": "4", "SLURMD_NODENAME": "cn-1"})
65
+
66
+
67
+ def test_shards_on_several_gpus_are_refused():
68
+ with pytest.raises(RuntimeError, match="several GPUs"):
69
+ shardguard.fraction(
70
+ {"SLURM_SHARDS_ON_NODE": "4", "CUDA_VISIBLE_DEVICES": "0,1"}
71
+ )
72
+
73
+
74
+ def settings(conf):
75
+ return dict(item.split(":", 1) for item in conf.split(","))
76
+
77
+
78
+ def test_existing_settings_are_kept_and_a_lower_cap_wins():
79
+ assert settings(shardguard.with_cap("expandable_segments:False", 0.5)) == {
80
+ "expandable_segments": "False",
81
+ "per_process_memory_fraction": "0.5",
82
+ }
83
+ assert settings(shardguard.with_cap("per_process_memory_fraction:0.1", 0.5)) == {
84
+ "expandable_segments": "True",
85
+ "per_process_memory_fraction": "0.1",
86
+ }
87
+ assert (
88
+ settings(shardguard.with_cap("per_process_memory_fraction:0.9", 0.5))[
89
+ "per_process_memory_fraction"
90
+ ]
91
+ == "0.5"
92
+ )
93
+
94
+
95
+ def test_the_cap_is_rounded_down_and_other_settings_are_left_alone():
96
+ conf = shardguard.with_cap("roundup_power2_divisions:[256:1,512:2]", 1 / 6)
97
+ assert (
98
+ conf
99
+ == "roundup_power2_divisions:[256:1,512:2],per_process_memory_fraction:0.1666,expandable_segments:True"
100
+ )
101
+
102
+
103
+ def test_old_or_missing_pytorch_is_refused(monkeypatch):
104
+ for version in ((2, 9), None):
105
+ monkeypatch.setattr(shardguard, "torch_version", lambda: version)
106
+ with pytest.raises(RuntimeError, match="2.10"):
107
+ shardguard.cap(
108
+ {"SLURM_SHARDS_ON_NODE": "4", shardguard.SHARDS_PER_GPU: "24"}
109
+ )
110
+
111
+
112
+ def test_the_async_allocator_is_refused():
113
+ with pytest.raises(RuntimeError, match="cudaMallocAsync"):
114
+ shardguard.cap(
115
+ {
116
+ "SLURM_SHARDS_ON_NODE": "4",
117
+ shardguard.SHARDS_PER_GPU: "24",
118
+ "PYTORCH_ALLOC_CONF": "backend:cudaMallocAsync",
119
+ }
120
+ )
121
+
122
+
123
+ def test_importing_after_cuda_is_initialised_is_refused(monkeypatch):
124
+ torch = types.SimpleNamespace(
125
+ cuda=types.SimpleNamespace(is_initialized=lambda: True)
126
+ )
127
+ monkeypatch.setitem(sys.modules, "torch", torch)
128
+ with pytest.raises(RuntimeError, match="before CUDA"):
129
+ shardguard.cap({"SLURM_SHARDS_ON_NODE": "4", shardguard.SHARDS_PER_GPU: "24"})