stream-limits 0.1.0__py3-none-any.whl
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.
- stream_limits/__init__.py +831 -0
- stream_limits/__main__.py +7 -0
- stream_limits/py.typed +0 -0
- stream_limits-0.1.0.dist-info/METADATA +159 -0
- stream_limits-0.1.0.dist-info/RECORD +8 -0
- stream_limits-0.1.0.dist-info/WHEEL +5 -0
- stream_limits-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- stream_limits-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"""Limits on the threads and memory a Python process may use, for jobs on a scheduler.
|
|
2
|
+
|
|
3
|
+
Polars, numba, BLAS and pyarrow each size their thread pools separately, and
|
|
4
|
+
each defaults to every core of the *node*, not of the job; memory guards that
|
|
5
|
+
look at the machine's physical memory see the node's too. This module finds
|
|
6
|
+
the allocation a job has (PBS, Slurm, cgroups, or your own setting), caps each
|
|
7
|
+
library at it as far as the library allows, and measures the memory a block of
|
|
8
|
+
code used.
|
|
9
|
+
|
|
10
|
+
It depends on nothing outside the standard library and imports none of the
|
|
11
|
+
libraries it limits, so it can run *before* they are imported, which matters:
|
|
12
|
+
Polars reads ``POLARS_MAX_THREADS`` (and its out-of-core memory budget) once,
|
|
13
|
+
at import, and nothing can change them afterwards. Put it first in a script::
|
|
14
|
+
|
|
15
|
+
import stream_limits
|
|
16
|
+
stream_limits.set_limits() # the job's allocation; or threads=8, memory_gb=16
|
|
17
|
+
|
|
18
|
+
import polars as pl # now capped
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
`set_limits` is `limit_threads` and `limit_memory` in one call; use those for
|
|
22
|
+
the options the one call leaves out.
|
|
23
|
+
|
|
24
|
+
The packages that use it (hdfe-stream, logit-stream, dpd-stream) call
|
|
25
|
+
`apply_scheduler_defaults` when imported, which does both only if it finds a
|
|
26
|
+
limit; on a laptop it does nothing. Importing *this* module changes nothing.
|
|
27
|
+
|
|
28
|
+
Where the limits come from, first match wins
|
|
29
|
+
threads the argument; ``STREAM_LIMITS_MAX_THREADS``; the variables named
|
|
30
|
+
in ``STREAM_LIMITS_CPU_VARS``; ``NCPUS``, ``PBS_NCPUS`` (PBS Pro),
|
|
31
|
+
``PBS_NUM_PPN``, ``PBS_NP`` (Torque); ``SLURM_CPUS_PER_TASK``,
|
|
32
|
+
``SLURM_CPUS_ON_NODE``; the cgroup CPU quota; PBS's own record of
|
|
33
|
+
the job, ``qstat -f $PBS_JOBID`` (``Resource_List.ncpus``, else
|
|
34
|
+
``select``, ``nodes``, ``procs``); the process's CPU affinity;
|
|
35
|
+
``os.cpu_count()``
|
|
36
|
+
memory ``STREAM_LIMITS_MEMORY_GB``; the variables named in
|
|
37
|
+
``STREAM_LIMITS_MEMORY_VARS``; the cgroup memory limit (the lowest
|
|
38
|
+
one on the path to the root); ``qstat -f $PBS_JOBID``
|
|
39
|
+
(``Resource_List.mem``, else ``vmem``), for sites that set no
|
|
40
|
+
memory variable and enforce no cgroup; ``SLURM_MEM_PER_NODE``, or
|
|
41
|
+
``SLURM_MEM_PER_CPU`` times the CPUs; the machine's physical memory
|
|
42
|
+
|
|
43
|
+
PBS Pro exports ``NCPUS`` to a job and no memory variable; Torque exports
|
|
44
|
+
``PBS_NUM_PPN`` and ``PBS_NP``. Sites differ beyond that, so any variable
|
|
45
|
+
that holds a CPU count or a size can be named in the two ``..._VARS``
|
|
46
|
+
settings.
|
|
47
|
+
|
|
48
|
+
What is limited
|
|
49
|
+
Polars, numba, BLAS (OpenBLAS, MKL, BLIS, Accelerate; NumPy and SciPy),
|
|
50
|
+
OpenMP (so LightGBM, XGBoost and scikit-learn's compiled code), pyarrow,
|
|
51
|
+
numexpr, rayon-based Rust libraries, TensorFlow, joblib's workers and
|
|
52
|
+
Julia: by environment variable, before they are imported. Libraries already
|
|
53
|
+
imported are also lowered at run time where they allow it (numba, BLAS,
|
|
54
|
+
pyarrow, numexpr, PyTorch). Variables you have set yourself are left alone,
|
|
55
|
+
and loaded libraries only lowered, unless ``override=True``. Libraries with
|
|
56
|
+
only per-object settings (DuckDB, Dask, Ray, JAX) are in the documentation's
|
|
57
|
+
"Other libraries".
|
|
58
|
+
|
|
59
|
+
Other environment variables
|
|
60
|
+
STREAM_LIMITS_MEMORY_RESERVE share of the memory limit `MemoryBudget`
|
|
61
|
+
keeps back (default 0.15)
|
|
62
|
+
STREAM_LIMITS_POLARS_SPILL ``0`` stops `limit_memory` setting Polars'
|
|
63
|
+
out-of-core budget
|
|
64
|
+
STREAM_LIMITS_POLARS_FRACTION the budget's share of the memory limit
|
|
65
|
+
(default 0.5)
|
|
66
|
+
STREAM_LIMITS_SPILL_DIR where Polars spills (default: its temp dir)
|
|
67
|
+
STREAM_LIMITS_QSTAT ``0`` stops the ``qstat`` lookups
|
|
68
|
+
STREAM_LIMITS_CPU_VARS comma-separated names of variables holding a
|
|
69
|
+
CPU count, checked before the built-in ones
|
|
70
|
+
STREAM_LIMITS_MEMORY_VARS the same for memory; a value is a PBS size
|
|
71
|
+
(``16gb``, ``512mb``; a bare number is bytes)
|
|
72
|
+
|
|
73
|
+
``python -m stream_limits`` prints what was found, which is the quickest way
|
|
74
|
+
to check a scheduler's settings from inside a job.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
import contextlib
|
|
80
|
+
import functools
|
|
81
|
+
import math
|
|
82
|
+
import os
|
|
83
|
+
import re
|
|
84
|
+
import subprocess
|
|
85
|
+
import sys
|
|
86
|
+
import warnings
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
from typing import Iterator, Mapping
|
|
89
|
+
|
|
90
|
+
__version__ = "0.1.0"
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
|
|
94
|
+
"detect_cpus", "detect_memory_gb", "describe", "MemoryBudget",
|
|
95
|
+
"peak_rss_gb", "reset_peak_rss", "current_rss_gb",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
# set to the thread count before the libraries are imported; a variable the user
|
|
99
|
+
# has already set is left alone unless `override` is given
|
|
100
|
+
_THREAD_ENV = (
|
|
101
|
+
"POLARS_MAX_THREADS", # Polars' pool, fixed at import
|
|
102
|
+
"NUMBA_NUM_THREADS", # numba's pool; it can be lowered later, never raised
|
|
103
|
+
"OMP_NUM_THREADS", # OpenMP, and so most BLAS builds, LightGBM, XGBoost, ...
|
|
104
|
+
"OMP_THREAD_LIMIT", # OpenMP's hard cap, which nested parallel regions respect
|
|
105
|
+
"OPENBLAS_NUM_THREADS",
|
|
106
|
+
"MKL_NUM_THREADS",
|
|
107
|
+
"BLIS_NUM_THREADS",
|
|
108
|
+
"VECLIB_MAXIMUM_THREADS", # Accelerate on macOS
|
|
109
|
+
"NUMEXPR_NUM_THREADS", # numexpr (and pandas' eval) ...
|
|
110
|
+
"NUMEXPR_MAX_THREADS", # ... whose pool is also bounded by this, 64 by default
|
|
111
|
+
"RAYON_NUM_THREADS", # Rust libraries on rayon's global pool (not Polars, which has its own)
|
|
112
|
+
"TF_NUM_INTRAOP_THREADS", # TensorFlow; fixed when it initializes
|
|
113
|
+
"TF_NUM_INTEROP_THREADS",
|
|
114
|
+
"LOKY_MAX_CPU_COUNT", # joblib's worker pool (and so scikit-learn's n_jobs=-1)
|
|
115
|
+
"JULIA_NUM_THREADS", # Julia started from Python
|
|
116
|
+
)
|
|
117
|
+
_CPU_ENV = ("STREAM_LIMITS_MAX_THREADS", "NCPUS", "PBS_NCPUS", "PBS_NUM_PPN", "PBS_NP",
|
|
118
|
+
"SLURM_CPUS_PER_TASK", "SLURM_CPUS_ON_NODE")
|
|
119
|
+
_UNLIMITED = 1 << 60 # cgroup v1 reports "no limit" as a huge number
|
|
120
|
+
_CGROUP = Path("/sys/fs/cgroup")
|
|
121
|
+
_PROC_CGROUP = Path("/proc/self/cgroup")
|
|
122
|
+
|
|
123
|
+
DEFAULT_RESERVE = 0.15 # share of the memory limit MemoryBudget keeps back
|
|
124
|
+
_POLARS_BUDGET_VARS = ("POLARS_OOC_MEMORY_BUDGET_MB", "POLARS_OOC_MEMORY_BUDGET_FRACTION")
|
|
125
|
+
_READER_FLOOR_GB = 1.5 # what Polars' Parquet readers hold beyond its budget
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------ detection
|
|
129
|
+
def _read(path: Path) -> str | None:
|
|
130
|
+
try:
|
|
131
|
+
return path.read_text().strip()
|
|
132
|
+
except (OSError, ValueError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cgroup_dirs(controller: str, root: Path, proc: Path) -> list[Path]:
|
|
137
|
+
"""The directories of this process's cgroup for a controller (``cpu`` or
|
|
138
|
+
``memory``) and of every parent up to the root: the effective limit is
|
|
139
|
+
the lowest along the path, because a scheduler usually limits a parent
|
|
140
|
+
(the job) and not the leaf the process sits in. Empty if there is none."""
|
|
141
|
+
text = _read(proc)
|
|
142
|
+
if not text:
|
|
143
|
+
return []
|
|
144
|
+
v2 = (root / "cgroup.controllers").exists()
|
|
145
|
+
rel = None
|
|
146
|
+
for line in text.splitlines():
|
|
147
|
+
_, ctrls, path = (line.split(":", 2) + ["", ""])[:3]
|
|
148
|
+
if v2 and ctrls == "":
|
|
149
|
+
rel = path
|
|
150
|
+
elif not v2 and controller in ctrls.split(","):
|
|
151
|
+
rel = path
|
|
152
|
+
if rel is None:
|
|
153
|
+
return []
|
|
154
|
+
base = root if v2 else root / controller
|
|
155
|
+
if not v2 and not base.exists(): # "cpu,cpuacct" is one mount
|
|
156
|
+
base = next((p for p in root.glob(f"*{controller}*") if p.is_dir()), base)
|
|
157
|
+
parts = [p for p in rel.split("/") if p]
|
|
158
|
+
dirs = [base.joinpath(*parts[:i]) for i in range(len(parts), -1, -1)]
|
|
159
|
+
existing = [d for d in dirs if d.is_dir()]
|
|
160
|
+
# inside a container the path may be "/" or not exist under this root
|
|
161
|
+
return existing or [base]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cgroup_cpu_limit(root: Path = _CGROUP, proc: Path = _PROC_CGROUP) -> float | None:
|
|
165
|
+
"""The CPU quota in CPUs (quota / period), or None if there is none."""
|
|
166
|
+
best = None
|
|
167
|
+
for d in _cgroup_dirs("cpu", root, proc):
|
|
168
|
+
quota = period = None
|
|
169
|
+
text = _read(d / "cpu.max") # v2: "200000 100000" or "max 100000"
|
|
170
|
+
if text:
|
|
171
|
+
q, _, p = text.partition(" ")
|
|
172
|
+
if q != "max" and q.isdigit() and p.isdigit():
|
|
173
|
+
quota, period = int(q), int(p)
|
|
174
|
+
else: # v1
|
|
175
|
+
q, p = _read(d / "cpu.cfs_quota_us"), _read(d / "cpu.cfs_period_us")
|
|
176
|
+
if q and p and q.lstrip("-").isdigit() and p.isdigit() and int(q) > 0:
|
|
177
|
+
quota, period = int(q), int(p)
|
|
178
|
+
if quota and period:
|
|
179
|
+
cpus = quota / period
|
|
180
|
+
best = cpus if best is None else min(best, cpus)
|
|
181
|
+
return best
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cgroup_memory_limit(root: Path = _CGROUP, proc: Path = _PROC_CGROUP) -> int | None:
|
|
185
|
+
"""The cgroup memory limit in bytes (the lowest on the path), or None."""
|
|
186
|
+
best = None
|
|
187
|
+
for d in _cgroup_dirs("memory", root, proc):
|
|
188
|
+
text = _read(d / "memory.max") or _read(d / "memory.limit_in_bytes")
|
|
189
|
+
if text and text.isdigit() and 0 < int(text) < _UNLIMITED:
|
|
190
|
+
best = int(text) if best is None else min(best, int(text))
|
|
191
|
+
return best
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _int_env(environ: Mapping[str, str], name: str) -> int | None:
|
|
195
|
+
try:
|
|
196
|
+
v = int(float(environ[name]))
|
|
197
|
+
except (KeyError, ValueError):
|
|
198
|
+
return None
|
|
199
|
+
return v if v > 0 else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _machine_cpus() -> int:
|
|
203
|
+
try:
|
|
204
|
+
return len(os.sched_getaffinity(0)) or 1
|
|
205
|
+
except AttributeError:
|
|
206
|
+
return os.cpu_count() or 1
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cpu_limit(environ, root, proc) -> tuple[int, str] | None:
|
|
210
|
+
"""The CPU count a scheduler, container or the user imposes, with where it
|
|
211
|
+
came from; None when nothing limits the machine."""
|
|
212
|
+
for name in (_CPU_ENV[0], *_named(environ, "STREAM_LIMITS_CPU_VARS"), *_CPU_ENV[1:]):
|
|
213
|
+
v = _int_env(environ, name)
|
|
214
|
+
if v:
|
|
215
|
+
return v, name
|
|
216
|
+
q = _cgroup_cpu_limit(root, proc)
|
|
217
|
+
if q is not None:
|
|
218
|
+
return max(1, math.ceil(q)), "cgroup cpu quota"
|
|
219
|
+
jobid = environ.get("PBS_JOBID")
|
|
220
|
+
if jobid and _qstat_enabled(environ):
|
|
221
|
+
found = _qstat_cpus(jobid)
|
|
222
|
+
if found: # a total over chunks must not exceed this machine
|
|
223
|
+
return min(found[0], _machine_cpus()), found[1]
|
|
224
|
+
try:
|
|
225
|
+
n_aff, n_all = len(os.sched_getaffinity(0)), os.cpu_count() or 0
|
|
226
|
+
except AttributeError: # macOS, Windows
|
|
227
|
+
return None
|
|
228
|
+
if 0 < n_aff < n_all:
|
|
229
|
+
return n_aff, "cpu affinity"
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def detect_cpus(environ: Mapping[str, str] | None = None, *,
|
|
234
|
+
cgroup_root: Path = _CGROUP, proc_cgroup: Path = _PROC_CGROUP) -> int:
|
|
235
|
+
"""The number of CPUs this process may use: the limit a scheduler,
|
|
236
|
+
container or ``STREAM_LIMITS_MAX_THREADS`` sets (see the module docstring
|
|
237
|
+
for the order), otherwise the machine's."""
|
|
238
|
+
environ = os.environ if environ is None else environ
|
|
239
|
+
lim = _cpu_limit(environ, Path(cgroup_root), Path(proc_cgroup))
|
|
240
|
+
return lim[0] if lim else _machine_cpus()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_SIZE_UNITS = {"": 1, "b": 1, "w": 8, "kb": 1024, "kw": 8 * 1024, "mb": 1024**2,
|
|
244
|
+
"mw": 8 * 1024**2, "gb": 1024**3, "gw": 8 * 1024**3, "tb": 1024**4,
|
|
245
|
+
"tw": 8 * 1024**4, "pb": 1024**5, "pw": 8 * 1024**5}
|
|
246
|
+
_JOBID = re.compile(r"^[A-Za-z0-9_.\-\[\]@:]+$")
|
|
247
|
+
_QSTAT_TIMEOUT = 10.0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_pbs_size(text: str) -> float | None:
|
|
251
|
+
"""A PBS size (``16gb``, ``512mb``, ``2tb``, ``1000000``; no suffix is
|
|
252
|
+
bytes, ``w`` is words of 8 bytes) in bytes."""
|
|
253
|
+
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*", text)
|
|
254
|
+
if not m or m.group(2).lower() not in _SIZE_UNITS:
|
|
255
|
+
return None
|
|
256
|
+
return float(m.group(1)) * _SIZE_UNITS[m.group(2).lower()]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _qstat_enabled(environ) -> bool:
|
|
260
|
+
return environ.get("STREAM_LIMITS_QSTAT", "1").strip().lower() not in ("0", "false", "no")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@functools.lru_cache(maxsize=8)
|
|
264
|
+
def _qstat_resources(jobid: str) -> dict | None:
|
|
265
|
+
"""The ``Resource_List.*`` attributes PBS records for a job, as strings.
|
|
266
|
+
Runs ``qstat -f <jobid>`` once per job (cached), without a shell and with a
|
|
267
|
+
timeout; None if there is no ``qstat``, it fails, or the id is not a job id."""
|
|
268
|
+
if not _JOBID.match(jobid):
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
out = subprocess.run(["qstat", "-f", jobid], capture_output=True, text=True,
|
|
272
|
+
timeout=_QSTAT_TIMEOUT, check=False)
|
|
273
|
+
except (OSError, subprocess.SubprocessError):
|
|
274
|
+
return None
|
|
275
|
+
if out.returncode != 0:
|
|
276
|
+
return None
|
|
277
|
+
# attributes are indented four spaces; a long value wraps onto lines that start with a tab
|
|
278
|
+
text = re.sub(r"\n\t", "", out.stdout)
|
|
279
|
+
found = dict(re.findall(r"^\s*Resource_List\.(\w+)\s*=\s*(\S+)\s*$", text, re.M))
|
|
280
|
+
return found or None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _qstat_memory(jobid: str) -> tuple[float, str] | None:
|
|
284
|
+
"""The memory PBS records for a job, in GB, and which field it came from:
|
|
285
|
+
``Resource_List.mem``, else ``vmem`` (virtual memory, a looser limit). For a
|
|
286
|
+
job with several chunks PBS reports the total over all of them."""
|
|
287
|
+
res = _qstat_resources(jobid)
|
|
288
|
+
for field in ("mem", "vmem"):
|
|
289
|
+
size = _parse_pbs_size(res[field]) if res and field in res else None
|
|
290
|
+
if size:
|
|
291
|
+
return size / 1e9, f"qstat Resource_List.{field}"
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _chunk_cpus(spec: str, key: str) -> int | None:
|
|
296
|
+
"""CPUs of the first chunk of a PBS ``select`` (``2:ncpus=8:mem=4gb+...``) or
|
|
297
|
+
Torque ``nodes`` (``2:ppn=4+...``) request: the chunk count times `key`."""
|
|
298
|
+
first = spec.split("+")[0].split(":")
|
|
299
|
+
count = int(first[0]) if first and first[0].isdigit() else 1
|
|
300
|
+
for tok in first:
|
|
301
|
+
if tok.startswith(key + "="):
|
|
302
|
+
try:
|
|
303
|
+
return count * int(tok.split("=", 1)[1])
|
|
304
|
+
except ValueError:
|
|
305
|
+
return None
|
|
306
|
+
return count if key == "ppn" and first and first[0].isdigit() else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _qstat_cpus(jobid: str) -> tuple[int, str] | None:
|
|
310
|
+
"""The CPUs PBS records for a job, and which field: ``Resource_List.ncpus``
|
|
311
|
+
(PBS Pro), else the first chunk of ``select`` (PBS Pro) or ``nodes`` (Torque),
|
|
312
|
+
else ``procs``. For a job with several chunks ``ncpus`` is the total over all
|
|
313
|
+
of them, and the caller caps it at the machine's CPUs."""
|
|
314
|
+
res = _qstat_resources(jobid)
|
|
315
|
+
if not res:
|
|
316
|
+
return None
|
|
317
|
+
v = res.get("ncpus")
|
|
318
|
+
if v and v.isdigit() and int(v) > 0:
|
|
319
|
+
return int(v), "qstat Resource_List.ncpus"
|
|
320
|
+
for field, key in (("select", "ncpus"), ("nodes", "ppn")):
|
|
321
|
+
if field in res:
|
|
322
|
+
n = _chunk_cpus(res[field], key)
|
|
323
|
+
if n:
|
|
324
|
+
return n, f"qstat Resource_List.{field}"
|
|
325
|
+
v = res.get("procs")
|
|
326
|
+
if v and v.isdigit() and int(v) > 0:
|
|
327
|
+
return int(v), "qstat Resource_List.procs"
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _named(environ, setting: str) -> list[str]:
|
|
332
|
+
return [n.strip() for n in environ.get(setting, "").split(",") if n.strip()]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _memory(environ, root, proc) -> tuple[float, str]:
|
|
336
|
+
v = environ.get("STREAM_LIMITS_MEMORY_GB")
|
|
337
|
+
try:
|
|
338
|
+
if v and float(v) > 0:
|
|
339
|
+
return float(v), "STREAM_LIMITS_MEMORY_GB"
|
|
340
|
+
except ValueError:
|
|
341
|
+
pass
|
|
342
|
+
for name in _named(environ, "STREAM_LIMITS_MEMORY_VARS"):
|
|
343
|
+
size = _parse_pbs_size(environ.get(name, ""))
|
|
344
|
+
if size:
|
|
345
|
+
return size / 1e9, name
|
|
346
|
+
cg = _cgroup_memory_limit(root, proc)
|
|
347
|
+
if cg:
|
|
348
|
+
return cg / 1e9, "cgroup memory limit"
|
|
349
|
+
jobid = environ.get("PBS_JOBID")
|
|
350
|
+
if jobid and _qstat_enabled(environ):
|
|
351
|
+
q = _qstat_memory(jobid)
|
|
352
|
+
if q:
|
|
353
|
+
return q
|
|
354
|
+
v = _int_env(environ, "SLURM_MEM_PER_NODE") # megabytes
|
|
355
|
+
if v:
|
|
356
|
+
return v * 1048576 / 1e9, "SLURM_MEM_PER_NODE"
|
|
357
|
+
v = _int_env(environ, "SLURM_MEM_PER_CPU") # megabytes per CPU
|
|
358
|
+
cpus = _int_env(environ, "SLURM_CPUS_ON_NODE") or _int_env(environ, "SLURM_CPUS_PER_TASK")
|
|
359
|
+
if v and cpus:
|
|
360
|
+
return v * cpus * 1048576 / 1e9, "SLURM_MEM_PER_CPU x CPUs"
|
|
361
|
+
try:
|
|
362
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9, "physical memory"
|
|
363
|
+
except (ValueError, OSError, AttributeError):
|
|
364
|
+
pass
|
|
365
|
+
if sys.platform == "win32":
|
|
366
|
+
import ctypes
|
|
367
|
+
|
|
368
|
+
class _Status(ctypes.Structure):
|
|
369
|
+
_fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong),
|
|
370
|
+
("total", ctypes.c_ulonglong), ("avail", ctypes.c_ulonglong),
|
|
371
|
+
("page_total", ctypes.c_ulonglong), ("page_avail", ctypes.c_ulonglong),
|
|
372
|
+
("virt_total", ctypes.c_ulonglong), ("virt_avail", ctypes.c_ulonglong),
|
|
373
|
+
("ext", ctypes.c_ulonglong)]
|
|
374
|
+
status = _Status(length=ctypes.sizeof(_Status))
|
|
375
|
+
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
|
376
|
+
return status.total / 1e9, "physical memory"
|
|
377
|
+
return 16.0, "default"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def detect_memory_gb(environ: Mapping[str, str] | None = None, *,
|
|
381
|
+
cgroup_root: Path = _CGROUP, proc_cgroup: Path = _PROC_CGROUP) -> float:
|
|
382
|
+
"""The memory in GB this process may use: ``STREAM_LIMITS_MEMORY_GB``, the
|
|
383
|
+
cgroup limit a scheduler sets for the job, or else the machine's physical
|
|
384
|
+
memory."""
|
|
385
|
+
environ = os.environ if environ is None else environ
|
|
386
|
+
return _memory(environ, Path(cgroup_root), Path(proc_cgroup))[0]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def describe(environ: Mapping[str, str] | None = None) -> dict:
|
|
390
|
+
"""What was found, and what the libraries are doing now: for a log line,
|
|
391
|
+
a bug report, or ``python -m stream_limits``."""
|
|
392
|
+
environ = os.environ if environ is None else environ
|
|
393
|
+
lim = _cpu_limit(environ, _CGROUP, _PROC_CGROUP)
|
|
394
|
+
mem, mem_src = _memory(environ, _CGROUP, _PROC_CGROUP)
|
|
395
|
+
out = {"cpus": detect_cpus(environ),
|
|
396
|
+
"cpus_source": lim[1] if lim else "machine (no limit found)",
|
|
397
|
+
"memory_gb": round(mem, 2), "memory_source": mem_src,
|
|
398
|
+
"env": {k: environ[k] for k in (
|
|
399
|
+
*_CPU_ENV, *_THREAD_ENV, *_POLARS_BUDGET_VARS, "POLARS_OOC_SPILL_DIR",
|
|
400
|
+
"STREAM_LIMITS_MEMORY_GB", "STREAM_LIMITS_MEMORY_RESERVE", "STREAM_LIMITS_CPU_VARS",
|
|
401
|
+
"STREAM_LIMITS_MEMORY_VARS", "PBS_JOBID", "SLURM_MEM_PER_NODE",
|
|
402
|
+
"SLURM_MEM_PER_CPU") if k in environ}}
|
|
403
|
+
pl = sys.modules.get("polars")
|
|
404
|
+
if pl is not None:
|
|
405
|
+
out["polars_threads"] = pl.thread_pool_size()
|
|
406
|
+
nb = sys.modules.get("numba")
|
|
407
|
+
if nb is not None:
|
|
408
|
+
out["numba_threads"] = nb.get_num_threads()
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------- a budget
|
|
413
|
+
class MemoryBudget:
|
|
414
|
+
"""How much memory is left to allocate, for guards that must choose between
|
|
415
|
+
a fast path that needs a lot of memory and a slower one that needs less.
|
|
416
|
+
|
|
417
|
+
``headroom_gb()`` is the limit, less the share `reserve` keeps back for
|
|
418
|
+
what the code does not model (allocator slack, transient copies, pages the
|
|
419
|
+
cgroup charges for files), less what the process holds now. A guard asks
|
|
420
|
+
whether *its exact allocation* fits, instead of comparing it with a fixed
|
|
421
|
+
fraction of the machine. Reading the process's memory is one file read.
|
|
422
|
+
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
limit_gb : float, optional
|
|
426
|
+
The memory the process may use. Default: `detect_memory_gb()`.
|
|
427
|
+
reserve : float, optional
|
|
428
|
+
The share of the limit kept back, 0 to 1. Default
|
|
429
|
+
``$STREAM_LIMITS_MEMORY_RESERVE``, else 0.15: lower it to trust the
|
|
430
|
+
estimates more, raise it for a job that must not be killed.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def __init__(self, limit_gb: float | None = None, reserve: float | None = None, *,
|
|
434
|
+
environ: Mapping[str, str] | None = None):
|
|
435
|
+
environ = os.environ if environ is None else environ
|
|
436
|
+
self.limit_gb = detect_memory_gb(environ) if limit_gb is None else float(limit_gb)
|
|
437
|
+
if reserve is None:
|
|
438
|
+
try:
|
|
439
|
+
reserve = float(environ.get("STREAM_LIMITS_MEMORY_RESERVE", DEFAULT_RESERVE))
|
|
440
|
+
except ValueError:
|
|
441
|
+
reserve = DEFAULT_RESERVE
|
|
442
|
+
if not 0 <= reserve < 1:
|
|
443
|
+
raise ValueError("reserve must be at least 0 and below 1")
|
|
444
|
+
if not self.limit_gb > 0:
|
|
445
|
+
raise ValueError("the memory limit must be positive")
|
|
446
|
+
self.reserve = float(reserve)
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def usable_gb(self) -> float:
|
|
450
|
+
"""The limit less the reserve."""
|
|
451
|
+
return self.limit_gb * (1 - self.reserve)
|
|
452
|
+
|
|
453
|
+
def held_gb(self) -> float:
|
|
454
|
+
"""What the process holds now (0.5 where it cannot be read)."""
|
|
455
|
+
rss = current_rss_gb()
|
|
456
|
+
return 0.5 if rss is None else rss
|
|
457
|
+
|
|
458
|
+
def headroom_gb(self, upcoming_gb: float = 0.0) -> float:
|
|
459
|
+
"""What can still be allocated, after `upcoming_gb` that will be
|
|
460
|
+
allocated later in the same phase; never negative."""
|
|
461
|
+
return max(0.0, self.usable_gb - self.held_gb() - upcoming_gb)
|
|
462
|
+
|
|
463
|
+
def fits(self, need_gb: float, upcoming_gb: float = 0.0, share: float = 1.0) -> bool:
|
|
464
|
+
"""Whether an allocation of `need_gb` fits within `share` of the headroom."""
|
|
465
|
+
return need_gb <= share * self.headroom_gb(upcoming_gb)
|
|
466
|
+
|
|
467
|
+
def __repr__(self):
|
|
468
|
+
return (f"MemoryBudget(limit_gb={self.limit_gb:.2f}, reserve={self.reserve:.2f}, "
|
|
469
|
+
f"headroom_gb={self.headroom_gb():.2f})")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ------------------------------------------------------------- limiting
|
|
473
|
+
def _above(value: str, n: int) -> bool:
|
|
474
|
+
try:
|
|
475
|
+
return int(value) > n
|
|
476
|
+
except ValueError: # "auto", "" ...
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _ceiling(n: int, current: int | None, override: bool, limit: int | None = None) -> int:
|
|
481
|
+
"""The thread count to set in a loaded library: `n`, capped at `limit` (a
|
|
482
|
+
library's launch-time maximum), and unless `override`, never above what it
|
|
483
|
+
already uses, so the cap lowers and never raises."""
|
|
484
|
+
target = n if limit is None else min(n, limit)
|
|
485
|
+
if not override and current is not None:
|
|
486
|
+
target = min(target, current)
|
|
487
|
+
return max(1, int(target))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def limit_threads(n: int | None = None, *, override: bool = False, environ=None,
|
|
491
|
+
_auto: bool = False, _stack: int = 0) -> dict:
|
|
492
|
+
"""Cap the threads that polars, numba, BLAS, pyarrow, numexpr and the other
|
|
493
|
+
libraries named in the module docstring use, as far as each allows.
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
n : int, optional
|
|
498
|
+
The cap. Default: `detect_cpus()`, the job's allocation.
|
|
499
|
+
override : bool, default False
|
|
500
|
+
By default a thread variable you have already set (``OMP_NUM_THREADS=1``,
|
|
501
|
+
say) is left alone, and loaded libraries are only ever lowered: the
|
|
502
|
+
cap is a ceiling, and a more specific choice of yours stays. A warning
|
|
503
|
+
names any variable you set above the cap. ``override=True`` replaces
|
|
504
|
+
the variables and sets the loaded libraries to exactly `n`.
|
|
505
|
+
|
|
506
|
+
Call it before importing polars. Before the libraries are imported it
|
|
507
|
+
sets their environment variables, which is the only way to limit Polars
|
|
508
|
+
(its pool is fixed at import). After they are imported it still lowers
|
|
509
|
+
numba's, BLAS's, pyarrow's, numexpr's and PyTorch's threads, but cannot
|
|
510
|
+
touch Polars', and says so with a warning; a limit Polars would have to
|
|
511
|
+
honor goes in ``STREAM_LIMITS_MAX_THREADS`` (or ``POLARS_MAX_THREADS``)
|
|
512
|
+
in the job script.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
-------
|
|
516
|
+
dict
|
|
517
|
+
What it did: ``set_env`` (variables set), ``kept`` (variables you had
|
|
518
|
+
set, with their values, left alone), ``lowered`` (libraries lowered at
|
|
519
|
+
run time) and ``too_late`` (libraries it could not limit).
|
|
520
|
+
"""
|
|
521
|
+
environ = os.environ if environ is None else environ
|
|
522
|
+
n = detect_cpus(environ) if n is None else int(n)
|
|
523
|
+
if n < 1:
|
|
524
|
+
raise ValueError("n must be at least 1")
|
|
525
|
+
report: dict = {"threads": n, "set_env": [], "kept": {}, "lowered": [], "too_late": []}
|
|
526
|
+
level = (4 if _auto else 2) + _stack
|
|
527
|
+
for name in _THREAD_ENV:
|
|
528
|
+
current = environ.get(name, "").strip()
|
|
529
|
+
if current and not override:
|
|
530
|
+
report["kept"][name] = current
|
|
531
|
+
continue
|
|
532
|
+
environ[name] = str(n)
|
|
533
|
+
report["set_env"].append(name)
|
|
534
|
+
above = {k: v for k, v in report["kept"].items() if _above(v, n)}
|
|
535
|
+
if above:
|
|
536
|
+
warnings.warn(
|
|
537
|
+
f"{', '.join(f'{k}={v}' for k, v in above.items())} {'is' if len(above) == 1 else 'are'} "
|
|
538
|
+
f"set above the thread limit {n} and left alone; pass override=True "
|
|
539
|
+
"(or unset it) to apply the limit.", RuntimeWarning, stacklevel=level)
|
|
540
|
+
pl = sys.modules.get("polars")
|
|
541
|
+
if pl is not None:
|
|
542
|
+
have = pl.thread_pool_size()
|
|
543
|
+
if have > n:
|
|
544
|
+
report["too_late"].append(f"polars ({have} threads)")
|
|
545
|
+
warnings.warn(
|
|
546
|
+
f"Polars was imported before the thread limit and uses {have} threads "
|
|
547
|
+
f"(limit {n}); its pool cannot be resized. Set STREAM_LIMITS_MAX_THREADS={n} "
|
|
548
|
+
"in the job script, or call stream_limits.limit_threads() before "
|
|
549
|
+
"importing polars.", RuntimeWarning, stacklevel=level)
|
|
550
|
+
nb = sys.modules.get("numba")
|
|
551
|
+
if nb is not None:
|
|
552
|
+
try:
|
|
553
|
+
nb.set_num_threads(_ceiling(n, nb.get_num_threads(), override,
|
|
554
|
+
nb.config.NUMBA_NUM_THREADS))
|
|
555
|
+
report["lowered"].append("numba")
|
|
556
|
+
except (ValueError, RuntimeError):
|
|
557
|
+
report["too_late"].append("numba")
|
|
558
|
+
if "numpy" in sys.modules:
|
|
559
|
+
try:
|
|
560
|
+
from threadpoolctl import ThreadpoolController
|
|
561
|
+
found = False
|
|
562
|
+
for lib in ThreadpoolController().lib_controllers:
|
|
563
|
+
if lib.user_api == "blas":
|
|
564
|
+
lib.set_num_threads(_ceiling(n, lib.num_threads, override))
|
|
565
|
+
found = True
|
|
566
|
+
# threadpoolctl sees OpenBLAS, MKL, BLIS and FlexiBLAS; not Apple's Accelerate,
|
|
567
|
+
# which only VECLIB_MAXIMUM_THREADS (before NumPy is imported) limits
|
|
568
|
+
if found:
|
|
569
|
+
report["lowered"].append("blas")
|
|
570
|
+
except ImportError:
|
|
571
|
+
pass
|
|
572
|
+
pa = sys.modules.get("pyarrow")
|
|
573
|
+
if pa is not None:
|
|
574
|
+
pa.set_cpu_count(_ceiling(n, pa.cpu_count(), override))
|
|
575
|
+
pa.set_io_thread_count(_ceiling(n, pa.io_thread_count(), override))
|
|
576
|
+
report["lowered"].append("pyarrow")
|
|
577
|
+
ne = sys.modules.get("numexpr")
|
|
578
|
+
if ne is not None:
|
|
579
|
+
try:
|
|
580
|
+
# (the module attribute ``nthreads`` is a copy made at import and goes stale)
|
|
581
|
+
current = ne.get_num_threads() if hasattr(ne, "get_num_threads") else None
|
|
582
|
+
ne.set_num_threads(_ceiling(n, current, override))
|
|
583
|
+
report["lowered"].append("numexpr")
|
|
584
|
+
except Exception: # noqa: BLE001
|
|
585
|
+
report["too_late"].append("numexpr")
|
|
586
|
+
torch = sys.modules.get("torch")
|
|
587
|
+
if torch is not None:
|
|
588
|
+
try:
|
|
589
|
+
torch.set_num_threads(_ceiling(n, torch.get_num_threads(), override))
|
|
590
|
+
report["lowered"].append("torch")
|
|
591
|
+
except Exception: # noqa: BLE001
|
|
592
|
+
report["too_late"].append("torch")
|
|
593
|
+
return report
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _polars_version() -> tuple[int, ...] | None:
|
|
597
|
+
"""The installed Polars' version, read from its metadata (so Polars is not
|
|
598
|
+
imported), or None if it is not installed."""
|
|
599
|
+
try:
|
|
600
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
601
|
+
try:
|
|
602
|
+
v = version("polars")
|
|
603
|
+
except PackageNotFoundError:
|
|
604
|
+
return None
|
|
605
|
+
except ImportError:
|
|
606
|
+
return None
|
|
607
|
+
parts = []
|
|
608
|
+
for tok in v.split(".")[:3]:
|
|
609
|
+
digits = "".join(ch for ch in tok if ch.isdigit())
|
|
610
|
+
parts.append(int(digits) if digits else 0)
|
|
611
|
+
return tuple(parts)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def polars_spills() -> bool:
|
|
615
|
+
"""Whether the installed Polars has the out-of-core memory budget that
|
|
616
|
+
`limit_memory` sets: 2.0 and later (it is an environment variable there,
|
|
617
|
+
undocumented, and read once at import)."""
|
|
618
|
+
v = _polars_version()
|
|
619
|
+
return v is not None and v >= (2, 0)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def limit_memory(limit_gb: float | None = None, *, fraction: float | None = None,
|
|
623
|
+
spill_dir: str | os.PathLike | None = None, override: bool = False,
|
|
624
|
+
environ=None, _auto: bool = False, _stack: int = 0) -> dict:
|
|
625
|
+
"""Give Polars a memory budget, past which its streaming engine spills to
|
|
626
|
+
disk instead of growing.
|
|
627
|
+
|
|
628
|
+
A streaming sort of 1.9 GB of Parquet peaks at 6.2 GB with Polars'
|
|
629
|
+
defaults, a multiple of the data; with a 400 MB budget it peaked at
|
|
630
|
+
1.8 GB, in twice the time. The budget bounds what the engine's operators
|
|
631
|
+
hold, not the whole process: the Parquet readers hold about 1.5 GB more on
|
|
632
|
+
8 threads (less on fewer), and the packages' own arrays are on top.
|
|
633
|
+
|
|
634
|
+
Parameters
|
|
635
|
+
----------
|
|
636
|
+
limit_gb : float, optional
|
|
637
|
+
The memory the process may use. Default: `detect_memory_gb()`.
|
|
638
|
+
fraction : float, optional
|
|
639
|
+
The budget's share of what is left after the readers' floor:
|
|
640
|
+
``fraction x (limit - 1.5 GB)``, at least 0.25 GB. Default
|
|
641
|
+
``$STREAM_LIMITS_POLARS_FRACTION``, else 0.5.
|
|
642
|
+
spill_dir : path, optional
|
|
643
|
+
Where Polars spills (``POLARS_OOC_SPILL_DIR``). Default: its own
|
|
644
|
+
temporary directory, which on a cluster should be the job's scratch
|
|
645
|
+
(check ``$TMPDIR``).
|
|
646
|
+
override : bool, default False
|
|
647
|
+
By default a Polars budget or spill directory you have already set in
|
|
648
|
+
the environment is left alone. ``override=True`` replaces them.
|
|
649
|
+
|
|
650
|
+
Like the thread limit this must come before Polars is imported (a warning
|
|
651
|
+
says so otherwise). It does nothing when Polars is older than 2.0, when a
|
|
652
|
+
budget is already set in the environment, or when
|
|
653
|
+
``STREAM_LIMITS_POLARS_SPILL=0``. Polars documents none of this in 2.x;
|
|
654
|
+
the names are those of its binary. Returns what it did."""
|
|
655
|
+
environ = os.environ if environ is None else environ
|
|
656
|
+
report: dict = {"budget_mb": None, "set_env": [], "skipped": None}
|
|
657
|
+
if environ.get("STREAM_LIMITS_POLARS_SPILL", "1").strip().lower() in ("0", "false", "no"):
|
|
658
|
+
report["skipped"] = "STREAM_LIMITS_POLARS_SPILL=0"
|
|
659
|
+
return report
|
|
660
|
+
if not polars_spills():
|
|
661
|
+
report["skipped"] = "Polars is not installed or is older than 2.0"
|
|
662
|
+
return report
|
|
663
|
+
preset = [v for v in _POLARS_BUDGET_VARS if environ.get(v, "").strip()]
|
|
664
|
+
if preset and not override:
|
|
665
|
+
report["skipped"] = "a Polars budget is already set"
|
|
666
|
+
return report
|
|
667
|
+
limit = detect_memory_gb(environ) if limit_gb is None else float(limit_gb)
|
|
668
|
+
if fraction is None:
|
|
669
|
+
try:
|
|
670
|
+
fraction = float(environ.get("STREAM_LIMITS_POLARS_FRACTION", 0.5))
|
|
671
|
+
except ValueError:
|
|
672
|
+
fraction = 0.5
|
|
673
|
+
if not 0 < fraction <= 1:
|
|
674
|
+
raise ValueError("fraction must be above 0 and at most 1")
|
|
675
|
+
budget_mb = int(max(0.25, fraction * (limit - _READER_FLOOR_GB)) * 1000)
|
|
676
|
+
if "polars" in sys.modules:
|
|
677
|
+
report["skipped"] = "Polars was imported first"
|
|
678
|
+
if not _auto or limit_gb is not None:
|
|
679
|
+
warnings.warn(
|
|
680
|
+
"Polars was imported before the memory limit; its out-of-core budget is read "
|
|
681
|
+
"at import and cannot be set now. Call stream_limits.limit_memory() before "
|
|
682
|
+
"importing polars, or set POLARS_OOC_MEMORY_BUDGET_MB in the job script.",
|
|
683
|
+
RuntimeWarning, stacklevel=(4 if _auto else 2) + _stack)
|
|
684
|
+
return report
|
|
685
|
+
for v in _POLARS_BUDGET_VARS: # the one budget, not two that disagree
|
|
686
|
+
environ.pop(v, None)
|
|
687
|
+
environ["POLARS_OOC_MEMORY_BUDGET_MB"] = str(budget_mb)
|
|
688
|
+
report["budget_mb"] = budget_mb
|
|
689
|
+
report["set_env"].append("POLARS_OOC_MEMORY_BUDGET_MB")
|
|
690
|
+
spill_dir = spill_dir or environ.get("STREAM_LIMITS_SPILL_DIR")
|
|
691
|
+
if spill_dir and (override or not environ.get("POLARS_OOC_SPILL_DIR", "").strip()):
|
|
692
|
+
environ["POLARS_OOC_SPILL_DIR"] = os.fspath(spill_dir)
|
|
693
|
+
report["set_env"].append("POLARS_OOC_SPILL_DIR")
|
|
694
|
+
return report
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def set_limits(threads: int | None = None, memory_gb: float | None = None, *,
|
|
698
|
+
spill_dir: str | os.PathLike | None = None, override: bool = False,
|
|
699
|
+
environ=None) -> dict:
|
|
700
|
+
"""Cap the threads and Polars' memory in one call: `limit_threads` and
|
|
701
|
+
`limit_memory`, for most scripts the only line needed, first, before
|
|
702
|
+
Polars is imported.
|
|
703
|
+
|
|
704
|
+
A value you give is also recorded in ``STREAM_LIMITS_MAX_THREADS`` or
|
|
705
|
+
``STREAM_LIMITS_MEMORY_GB``, so `detect_cpus`, `detect_memory_gb` and
|
|
706
|
+
`MemoryBudget` in this process (and in the packages built on this one, and in
|
|
707
|
+
child processes) agree with it. ``set_limits()`` with no arguments does what
|
|
708
|
+
`apply_scheduler_defaults`, the call the libraries make when imported, does.
|
|
709
|
+
|
|
710
|
+
Parameters
|
|
711
|
+
----------
|
|
712
|
+
threads : int, optional
|
|
713
|
+
The thread cap. Given, it is applied (and overrides thread variables
|
|
714
|
+
already set). Omitted, it is the job's CPU allocation if one is found
|
|
715
|
+
(see `detect_cpus`) and nothing otherwise.
|
|
716
|
+
memory_gb : float, optional
|
|
717
|
+
The memory limit in GB. Given, it is applied. Omitted, it is the job's
|
|
718
|
+
allocation if one is found (see `detect_memory_gb`) and nothing
|
|
719
|
+
otherwise: a workstation with no limit gets no Polars budget.
|
|
720
|
+
spill_dir : path, optional
|
|
721
|
+
Where Polars spills; see `limit_memory`.
|
|
722
|
+
override : bool, default False
|
|
723
|
+
Replace thread and Polars variables you have already set, instead of
|
|
724
|
+
leaving them alone (and set loaded libraries to exactly `threads`
|
|
725
|
+
instead of only lowering them); see `limit_threads`.
|
|
726
|
+
|
|
727
|
+
Returns
|
|
728
|
+
-------
|
|
729
|
+
dict
|
|
730
|
+
``{"threads": ..., "memory": ...}``, each the report of `limit_threads`
|
|
731
|
+
or `limit_memory`, left out when there was no limit to apply.
|
|
732
|
+
"""
|
|
733
|
+
environ = os.environ if environ is None else environ
|
|
734
|
+
out: dict = {}
|
|
735
|
+
if threads is not None:
|
|
736
|
+
out["threads"] = limit_threads(threads, override=override, environ=environ, _stack=1)
|
|
737
|
+
environ["STREAM_LIMITS_MAX_THREADS"] = str(int(threads))
|
|
738
|
+
else:
|
|
739
|
+
lim = _cpu_limit(environ, _CGROUP, _PROC_CGROUP)
|
|
740
|
+
if lim is not None:
|
|
741
|
+
out["threads"] = limit_threads(lim[0], override=override, environ=environ,
|
|
742
|
+
_auto=True, _stack=-1)
|
|
743
|
+
if memory_gb is not None:
|
|
744
|
+
out["memory"] = limit_memory(memory_gb, spill_dir=spill_dir, override=override,
|
|
745
|
+
environ=environ, _stack=1)
|
|
746
|
+
environ["STREAM_LIMITS_MEMORY_GB"] = str(float(memory_gb))
|
|
747
|
+
else:
|
|
748
|
+
mem, src = _memory(environ, _CGROUP, _PROC_CGROUP)
|
|
749
|
+
if src not in ("physical memory", "default"):
|
|
750
|
+
out["memory"] = limit_memory(mem, spill_dir=spill_dir, override=override,
|
|
751
|
+
environ=environ, _auto=True, _stack=-1)
|
|
752
|
+
return out
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def apply_scheduler_defaults(environ=None) -> dict | None:
|
|
756
|
+
"""Cap the threads and Polars' memory at the scheduler's allocation, if
|
|
757
|
+
there is one. The estimator packages call this first thing when imported.
|
|
758
|
+
It acts only on limits it finds (a variable, a scheduler's environment,
|
|
759
|
+
a cgroup, a restricted CPU affinity): the thread cap sets only the
|
|
760
|
+
variables the user has not set; with no limit it does nothing, so a
|
|
761
|
+
workstation is untouched. Returns ``{"threads": ..., "memory": ...}``
|
|
762
|
+
(each the report of `limit_threads` / `limit_memory`), or None."""
|
|
763
|
+
environ = os.environ if environ is None else environ
|
|
764
|
+
out: dict = {}
|
|
765
|
+
lim = _cpu_limit(environ, _CGROUP, _PROC_CGROUP)
|
|
766
|
+
if lim is not None:
|
|
767
|
+
out["threads"] = limit_threads(lim[0], environ=environ, _auto=True)
|
|
768
|
+
mem, src = _memory(environ, _CGROUP, _PROC_CGROUP)
|
|
769
|
+
if src not in ("physical memory", "default"):
|
|
770
|
+
out["memory"] = limit_memory(mem, environ=environ, _auto=True)
|
|
771
|
+
return out or None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@contextlib.contextmanager
|
|
775
|
+
def thread_scope(n: int | None = None) -> Iterator[None]:
|
|
776
|
+
"""Hold numba and BLAS to `n` threads inside the block and restore them
|
|
777
|
+
after. ``None`` (or 0) leaves everything as it is. Polars is not affected:
|
|
778
|
+
its pool is fixed for the process."""
|
|
779
|
+
if not n:
|
|
780
|
+
yield
|
|
781
|
+
return
|
|
782
|
+
import numba
|
|
783
|
+
from threadpoolctl import threadpool_limits
|
|
784
|
+
previous = numba.get_num_threads()
|
|
785
|
+
numba.set_num_threads(max(1, min(int(n), numba.config.NUMBA_NUM_THREADS)))
|
|
786
|
+
try:
|
|
787
|
+
with threadpool_limits(limits=int(n), user_api="blas"):
|
|
788
|
+
yield
|
|
789
|
+
finally:
|
|
790
|
+
numba.set_num_threads(previous)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ----------------------------------------------------- peak memory used
|
|
794
|
+
def reset_peak_rss() -> None:
|
|
795
|
+
"""Restart the peak-memory counter at the current usage (Linux; elsewhere
|
|
796
|
+
it does nothing, and `peak_rss_gb` then reports the process's lifetime
|
|
797
|
+
peak)."""
|
|
798
|
+
try:
|
|
799
|
+
with open("/proc/self/clear_refs", "w") as f:
|
|
800
|
+
f.write("5")
|
|
801
|
+
except OSError:
|
|
802
|
+
pass
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def peak_rss_gb() -> float | None:
|
|
806
|
+
"""The peak resident memory, in GB, since `reset_peak_rss` (or since the
|
|
807
|
+
process started). The kernel keeps the counter, so reading it is free and
|
|
808
|
+
nothing is polled. Child processes are not included."""
|
|
809
|
+
try:
|
|
810
|
+
for line in Path("/proc/self/status").read_text().splitlines():
|
|
811
|
+
if line.startswith("VmHWM:"):
|
|
812
|
+
return int(line.split()[1]) * 1024 / 1e9
|
|
813
|
+
except (OSError, ValueError, IndexError):
|
|
814
|
+
pass
|
|
815
|
+
try:
|
|
816
|
+
import resource
|
|
817
|
+
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
818
|
+
except (ImportError, OSError):
|
|
819
|
+
return None
|
|
820
|
+
return peak / 1e9 if sys.platform == "darwin" else peak * 1024 / 1e9 # bytes vs KB
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def current_rss_gb() -> float | None:
|
|
824
|
+
"""The resident memory in GB right now (Linux; None elsewhere)."""
|
|
825
|
+
try:
|
|
826
|
+
for line in Path("/proc/self/status").read_text().splitlines():
|
|
827
|
+
if line.startswith("VmRSS:"):
|
|
828
|
+
return int(line.split()[1]) * 1024 / 1e9
|
|
829
|
+
except (OSError, ValueError, IndexError):
|
|
830
|
+
pass
|
|
831
|
+
return None
|
stream_limits/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stream-limits
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory
|
|
5
|
+
Author: Lee Tucker
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/leetucker/stream-limits
|
|
8
|
+
Project-URL: Documentation, https://leetucker.github.io/stream-limits/
|
|
9
|
+
Project-URL: Changelog, https://github.com/leetucker/stream-limits/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Source, https://github.com/leetucker/stream-limits
|
|
11
|
+
Keywords: threads,memory,PBS,Slurm,cgroup,polars,numba,HPC
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
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: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
24
|
+
Classifier: Topic :: System :: Hardware
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE.md
|
|
29
|
+
Provides-Extra: blas
|
|
30
|
+
Requires-Dist: threadpoolctl>=3.1; extra == "blas"
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: sphinx>=8.0; extra == "docs"
|
|
33
|
+
Requires-Dist: myst-parser>=4.0; extra == "docs"
|
|
34
|
+
Requires-Dist: furo>=2024.8.6; extra == "docs"
|
|
35
|
+
Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
38
|
+
Requires-Dist: numpy; extra == "test"
|
|
39
|
+
Requires-Dist: numba; extra == "test"
|
|
40
|
+
Requires-Dist: polars; extra == "test"
|
|
41
|
+
Requires-Dist: pyarrow; extra == "test"
|
|
42
|
+
Requires-Dist: threadpoolctl>=3.1; extra == "test"
|
|
43
|
+
Requires-Dist: numexpr; python_version < "3.15" and extra == "test"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# stream-limits
|
|
47
|
+
|
|
48
|
+
Cap the threads and memory of numeric Python libraries at a job's allocation, with one
|
|
49
|
+
interface, and measure the memory a block of code used.
|
|
50
|
+
|
|
51
|
+
On a cluster (PBS, Slurm, a container) a job gets a fixed number of CPUs and a fixed
|
|
52
|
+
amount of memory, and is killed if it exceeds either. Polars, numba, BLAS and pyarrow
|
|
53
|
+
each size their thread pools separately and default to every core of the *node*; code
|
|
54
|
+
that asks how much memory there is sees the node's too. `stream-limits` finds the job's
|
|
55
|
+
allocation, applies it to each library as far as the library allows, and says so when it
|
|
56
|
+
cannot. It depends on nothing outside the standard library and imports none of the
|
|
57
|
+
libraries it limits, so it can run before they are imported, which is when some of them
|
|
58
|
+
(Polars) need to be told.
|
|
59
|
+
|
|
60
|
+
It is what [hdfe-stream](https://github.com/leetucker/hdfe-stream),
|
|
61
|
+
[logit-stream](https://github.com/leetucker/logit-stream) and
|
|
62
|
+
[dpd-stream](https://github.com/leetucker/dpd-stream) use, and it works on its own.
|
|
63
|
+
Documentation: <https://leetucker.github.io/stream-limits/> (build locally with
|
|
64
|
+
`pip install -e ".[docs]"` and `sphinx-build -W -b html docs docs/_build/html`).
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import stream_limits
|
|
68
|
+
stream_limits.set_limits() # the job's allocation; or set_limits(threads=8, memory_gb=16)
|
|
69
|
+
|
|
70
|
+
import polars as pl # now capped
|
|
71
|
+
import numpy as np
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
$ python -m stream_limits # what was found, from inside a job
|
|
76
|
+
{"cpus": 8, "cpus_source": "PBS_NCPUS", "memory_gb": 32.0, "memory_source": "cgroup memory limit", ...}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Variables you have set are **not overridden** by default (a warning names any you set above the cap);
|
|
80
|
+
loaded libraries are only lowered. `override=True` replaces them. More libraries are covered than the
|
|
81
|
+
table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia), and the documentation's
|
|
82
|
+
"Other libraries" has recipes for those with only per-object settings (DuckDB, Dask, Ray, JAX, Spark).
|
|
83
|
+
|
|
84
|
+
## What each library allows
|
|
85
|
+
|
|
86
|
+
| Library | Can it be capped after import? | What `limit_threads` does |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
89
|
+
| numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
|
|
90
|
+
| BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
|
|
91
|
+
| pyarrow | yes | `set_cpu_count`, `set_io_thread_count` |
|
|
92
|
+
|
|
93
|
+
`thread_scope(n)` holds numba and BLAS to `n` threads for a block and restores them.
|
|
94
|
+
|
|
95
|
+
## Memory
|
|
96
|
+
|
|
97
|
+
- `detect_memory_gb()` is the memory the process may use: `STREAM_LIMITS_MEMORY_GB`, else the
|
|
98
|
+
cgroup limit (the lowest on the path to the root, so a scheduler's job limit is found), else
|
|
99
|
+
PBS's own record of the job, else `SLURM_MEM_PER_NODE`, else the machine's. PBS sites often set
|
|
100
|
+
no memory variable and enforce no cgroup; there `qstat -f $PBS_JOBID` is run once (no shell, a
|
|
101
|
+
10 s timeout, cached) and `Resource_List.mem` (else `vmem`) is read, with PBS's size suffixes.
|
|
102
|
+
`STREAM_LIMITS_QSTAT=0` turns this off.
|
|
103
|
+
- `limit_memory()` gives Polars 2.x an out-of-core budget (`POLARS_OOC_MEMORY_BUDGET_MB`),
|
|
104
|
+
past which its streaming engine spills to disk instead of growing. A streaming sort of 1.9 GB
|
|
105
|
+
of Parquet peaked at 6.2 GB with the defaults and 1.8 GB with a 400 MB budget, in twice the
|
|
106
|
+
time. The budget bounds what Polars' operators hold, not the whole process (its Parquet
|
|
107
|
+
readers hold about 1.5 GB more on 8 threads). This is undocumented in Polars 2.x and read at
|
|
108
|
+
import, so it is set only for Polars 2.0 or later, only before Polars is imported, never over
|
|
109
|
+
a budget you set, and `STREAM_LIMITS_POLARS_SPILL=0` turns it off.
|
|
110
|
+
- `MemoryBudget(limit_gb, reserve)` is for code that chooses between a fast path that needs
|
|
111
|
+
memory and a slower one that needs less: `budget.headroom_gb()` is the limit, less a reserve
|
|
112
|
+
(default 15%, `STREAM_LIMITS_MEMORY_RESERVE`) for what the code does not model, less what the
|
|
113
|
+
process holds now. Ask whether your exact allocation fits, not whether it is under a fixed
|
|
114
|
+
fraction of the machine.
|
|
115
|
+
- `peak_rss_gb()` / `reset_peak_rss()` read the kernel's own high-water mark (Linux): free, no
|
|
116
|
+
polling.
|
|
117
|
+
|
|
118
|
+
`set_limits` is `limit_threads` and `limit_memory` in one call: values you give are applied, values you
|
|
119
|
+
leave out are the job's allocation if one is found and nothing otherwise. The two remain, for their
|
|
120
|
+
own options (`spill_dir`, `fraction`, ...).
|
|
121
|
+
|
|
122
|
+
## Automatically
|
|
123
|
+
|
|
124
|
+
`apply_scheduler_defaults()` (which is `set_limits()` with no arguments) does `limit_threads` and `limit_memory`, only for limits it finds
|
|
125
|
+
(a variable, a scheduler's environment, a cgroup, a restricted CPU affinity), and sets only
|
|
126
|
+
variables you have not set. Libraries built on this call it when they are imported; importing
|
|
127
|
+
`stream_limits` itself changes nothing.
|
|
128
|
+
|
|
129
|
+
## Environment variables
|
|
130
|
+
|
|
131
|
+
| Variable | Meaning |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `STREAM_LIMITS_MAX_THREADS` | the thread cap |
|
|
134
|
+
| `STREAM_LIMITS_MEMORY_GB` | the memory limit |
|
|
135
|
+
| `STREAM_LIMITS_MEMORY_RESERVE` | `MemoryBudget`'s reserve (default 0.15) |
|
|
136
|
+
| `STREAM_LIMITS_POLARS_SPILL` | `0`: do not set Polars' budget |
|
|
137
|
+
| `STREAM_LIMITS_POLARS_FRACTION` | the budget's share of the limit above the readers' floor (default 0.5) |
|
|
138
|
+
| `STREAM_LIMITS_QSTAT` | `0`: do not run `qstat` to find a PBS job's CPUs and memory |
|
|
139
|
+
| `STREAM_LIMITS_CPU_VARS` | names of variables holding a CPU count, checked first (comma-separated) |
|
|
140
|
+
| `STREAM_LIMITS_MEMORY_VARS` | the same for memory; each holds a size (`16gb`) |
|
|
141
|
+
| `STREAM_LIMITS_SPILL_DIR` | where Polars spills (default its temp dir: on a cluster, check `$TMPDIR`) |
|
|
142
|
+
|
|
143
|
+
Thread limits are read from, in order: the argument; `STREAM_LIMITS_MAX_THREADS`; the variables named
|
|
144
|
+
in `STREAM_LIMITS_CPU_VARS`; `NCPUS`, `PBS_NCPUS`, `PBS_NUM_PPN`, `PBS_NP`; `SLURM_CPUS_PER_TASK`,
|
|
145
|
+
`SLURM_CPUS_ON_NODE`; the cgroup CPU quota; `qstat -f $PBS_JOBID`; the CPU affinity; `os.cpu_count()`.
|
|
146
|
+
See the documentation's "Schedulers and containers" for each source and for sites that do it differently.
|
|
147
|
+
|
|
148
|
+
## Install
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
pip install stream-limits # nothing else is installed
|
|
152
|
+
pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Linux and macOS are tested on Python 3.10 to 3.15, Windows on 3.10 and 3.14. On Windows the module imports
|
|
156
|
+
and applies thread limits, but it detects no cgroup and cannot read peak memory. On macOS, NumPy's Apple
|
|
157
|
+
Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
|
|
158
|
+
|
|
159
|
+
MIT licence.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
stream_limits/__init__.py,sha256=gC12hsEqWYwVfGJ9ZPGoFtWLw8RbwCr_vv-fIAUBKDQ,38030
|
|
2
|
+
stream_limits/__main__.py,sha256=05gP-3YF1RE5cxsmTe_TK3DwpvWLAWAZq2HpysKNEfw,138
|
|
3
|
+
stream_limits/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
stream_limits-0.1.0.dist-info/licenses/LICENSE.md,sha256=3DbDbxSR7ThJQtEanali1D4l4GzY_A_wfUBJB-tlCnU,1068
|
|
5
|
+
stream_limits-0.1.0.dist-info/METADATA,sha256=_Aytfy2ObSUOBxwKGhoJp0vaNtSaSBudeNogZBWx2Mw,8593
|
|
6
|
+
stream_limits-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
stream_limits-0.1.0.dist-info/top_level.txt,sha256=XMYpxJaTl1urgmAyWDuDVFuoPpEIk7cJFBCoOLSUOjM,14
|
|
8
|
+
stream_limits-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lee Tucker
|
|
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 @@
|
|
|
1
|
+
stream_limits
|