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.
@@ -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
@@ -0,0 +1,7 @@
1
+ """``python -m stream_limits``: what was found, as JSON."""
2
+
3
+ import json
4
+
5
+ from . import describe
6
+
7
+ print(json.dumps(describe(), indent=2))
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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