stream-limits 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stream_limits-0.2.0 → stream_limits-0.3.0}/CHANGELOG.md +35 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/CITATION.cff +2 -2
- {stream_limits-0.2.0 → stream_limits-0.3.0}/PKG-INFO +1 -1
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/launcher.md +29 -12
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/reference.md +2 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/threads.md +20 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/pyproject.toml +1 -1
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/__init__.py +52 -5
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/launcher.py +82 -6
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/PKG-INFO +1 -1
- {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_launcher.py +67 -3
- {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_override_and_libraries.py +65 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/LICENSE.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/MANIFEST.in +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/README.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/_static/.gitkeep +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/changelog.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/conf.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/index.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/memory.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/other-libraries.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/schedulers.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/setup.cfg +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/__main__.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/_exec_cgroup.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/enforcement.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/py.typed +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/pytest_plugin.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/testing.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/SOURCES.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/dependency_links.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/requires.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/top_level.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/conftest.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_limits.py +0 -0
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- **A new function, `set_openmp_wait_policy`, controls how idle OpenMP threads
|
|
6
|
+
wait.** Programs that make many short parallel calls, such as numba loops run
|
|
7
|
+
once per batch of data, can run noticeably slower when the idle threads spin
|
|
8
|
+
while they wait for the next call, especially on processors that mix fast and
|
|
9
|
+
efficient cores. Telling the threads to sleep instead (`OMP_WAIT_POLICY=passive`,
|
|
10
|
+
the default here) fixed this in diff-stream: fits ran 2% to 15% faster on a
|
|
11
|
+
cloud server, and on a laptop with mixed cores 8 threads went from slower than
|
|
12
|
+
one thread to faster. Programs with a few long parallel calls, such as
|
|
13
|
+
hdfe-stream and logit-stream, run the same either way. On Windows, where
|
|
14
|
+
numba's OpenMP runtime ignores the setting, the call changes nothing.
|
|
15
|
+
- **A package has to ask for it.** Importing stream-limits, and
|
|
16
|
+
`apply_scheduler_defaults`, still change nothing, because the setting reaches
|
|
17
|
+
every OpenMP library in the process (BLAS builds on OpenMP, scikit-learn,
|
|
18
|
+
LightGBM). A value you have set yourself is kept unless you pass
|
|
19
|
+
`override=True`.
|
|
20
|
+
- **It must run before OpenMP starts.** The setting is read once, when the
|
|
21
|
+
OpenMP runtime loads; numba loads its runtime at its first parallel call, so
|
|
22
|
+
calling the function after importing numba is still in time. The function
|
|
23
|
+
reports when a runtime was already loaded (Linux), in which case the setting
|
|
24
|
+
reaches only programs started afterwards.
|
|
25
|
+
|
|
26
|
+
## 0.2.1
|
|
27
|
+
|
|
28
|
+
- **The watchdog no longer counts the pages of memory-mapped files.** A program
|
|
29
|
+
that memory-maps large scratch files, as hdfe-stream does, has those pages in
|
|
30
|
+
its resident memory, but the kernel can reclaim them, so a watchdog that
|
|
31
|
+
counted them stopped healthy programs: a 20-million-row hdfe-stream fit with
|
|
32
|
+
1.2 GB of its own memory reached 2.3 GB of RSS. The watchdog now limits
|
|
33
|
+
private memory (Linux, Windows; macOS still uses RSS), reported as
|
|
34
|
+
`peak_private_bytes`. `--memory-measure rss` restores the old behaviour.
|
|
35
|
+
- On Windows, `os_peak_rss_bytes` is now the program's exact peak working set,
|
|
36
|
+
read when it exits, instead of the last value seen while sampling.
|
|
37
|
+
|
|
3
38
|
## 0.2.0
|
|
4
39
|
|
|
5
40
|
- **Run a program under a memory limit and see how much memory it used.** The new
|
|
@@ -7,8 +7,8 @@ authors:
|
|
|
7
7
|
- family-names: Tucker
|
|
8
8
|
given-names: Lee
|
|
9
9
|
email: lee.c.tucker@census.gov
|
|
10
|
-
version: 0.
|
|
11
|
-
date-released: 2026-11-
|
|
10
|
+
version: 0.3.0
|
|
11
|
+
date-released: 2026-11-08
|
|
12
12
|
license: MIT
|
|
13
13
|
repository-code: "https://github.com/leetucker/stream-limits"
|
|
14
14
|
url: "https://github.com/leetucker/stream-limits"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stream-limits
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
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
5
|
Author: Lee Tucker
|
|
6
6
|
License-Expression: MIT
|
|
@@ -49,7 +49,18 @@ The command is a list of arguments, run without a shell. GB are decimal
|
|
|
49
49
|
| `monitor` | Linux, macOS, Windows | No limit: measures, with an optional timeout |
|
|
50
50
|
|
|
51
51
|
**The watchdog** adds up the memory of the program and every process it has
|
|
52
|
-
started, and stops them all when the total passes the limit.
|
|
52
|
+
started, and stops them all when the total passes the limit. It counts
|
|
53
|
+
*private* memory: memory that is not the pages of a file. A program that
|
|
54
|
+
memory-maps a large file (a scratch array on disk, a mapped Parquet file) has
|
|
55
|
+
the file's pages in its resident memory (RSS), but the kernel can drop them and
|
|
56
|
+
read them back when memory runs short, so they are not counted against the
|
|
57
|
+
limit. hdfe-stream is such a program: on a 20-million-row panel its RSS reached
|
|
58
|
+
2.3 GB while its private memory stayed at 1.2 GB, the same as on 2.6 million
|
|
59
|
+
rows. On Linux private memory is anonymous plus shared memory; on Windows it is
|
|
60
|
+
private bytes (committed memory, which can be more than RSS). macOS has no cheap
|
|
61
|
+
equivalent, so there the watchdog counts RSS. `--memory-measure rss`
|
|
62
|
+
(`memory_measure="rss"`) counts every resident page on every platform, and the
|
|
63
|
+
result's `memory_measure` says which was used. Because it samples,
|
|
53
64
|
it can overshoot a little before it acts, and a peak shorter than the interval
|
|
54
65
|
can slip between samples. In practice it is tight: on Linux, with a 1.5 GB limit,
|
|
55
66
|
a fast 3 GB NumPy allocation, a 2.5 GB spike lasting 20 ms and a grandchild
|
|
@@ -74,7 +85,7 @@ running under the watchdog. macOS and Windows have no hard mode.
|
|
|
74
85
|
| `ok` | The program finished with exit code 0 |
|
|
75
86
|
| `command_failed` | It finished with another exit code |
|
|
76
87
|
| `timeout` | It ran past `timeout` and was stopped |
|
|
77
|
-
| `rss_limit_exceeded` | The watchdog stopped it |
|
|
88
|
+
| `rss_limit_exceeded` | The watchdog stopped it (by the measure in `memory_measure`, despite the name) |
|
|
78
89
|
| `monitoring_failed` | The watchdog could not read its memory and stopped it |
|
|
79
90
|
| `kernel_oom` | The kernel killed a process for going over a hard limit |
|
|
80
91
|
|
|
@@ -83,16 +94,19 @@ process. It is reported even when the process killed was a child the program
|
|
|
83
94
|
started and the program itself carried on and exited with 0, so check `status`
|
|
84
95
|
and not only the exit code.
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
Three peaks are reported:
|
|
87
98
|
|
|
88
|
-
- `
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
- `peak_private_bytes`: the largest sampled total of private memory over all the
|
|
100
|
+
program's processes (`None` on macOS). This is the one to use for a program
|
|
101
|
+
that memory-maps files, and the one the watchdog limits.
|
|
102
|
+
- `os_peak_rss_bytes`: the largest RSS of any single process, as the operating
|
|
103
|
+
system records it. On Linux and macOS it is exact, and covers the program and
|
|
104
|
+
every process it started and waited for (not one it left running in the
|
|
105
|
+
background). On Windows it is the program's own peak working set, read when
|
|
106
|
+
it exits (not its child processes'). It counts mapped file pages. Use it when a test needs a reliable peak of a program that maps no
|
|
107
|
+
large files, or on macOS.
|
|
108
|
+
- `peak_rss_bytes`: the largest sampled total RSS over all the program's
|
|
109
|
+
processes at once. A program that ends before the first sample reports 0.
|
|
96
110
|
|
|
97
111
|
With a hard limit, `result.kernel` adds the kernel's own figures: the limit, any
|
|
98
112
|
smaller limit set higher up, its peak (which counts file cache and kernel
|
|
@@ -230,7 +244,10 @@ and skip without one. The plugin registers the markers `resource` and
|
|
|
230
244
|
Polars, NumPy and numba already holds 0.2-0.4 GB, so a small input under a 1.5 GB
|
|
231
245
|
limit passes whatever the code does with it. To catch code that holds the data in
|
|
232
246
|
memory, give the test an input larger in memory than the limit less that
|
|
233
|
-
baseline, or compare
|
|
247
|
+
baseline, or compare peaks at N rows and at several times N:
|
|
248
|
+
`peak_private_bytes` where it is available, since a program that memory-maps
|
|
249
|
+
its scratch files has an RSS that grows with the data even when its private
|
|
250
|
+
memory does not.
|
|
234
251
|
|
|
235
252
|
Use the launcher only for tests about memory. Ordinary tests are better run in
|
|
236
253
|
the test process, as before.
|
|
@@ -81,3 +81,23 @@ allocation, read from the environment, cgroups or the scheduler (see
|
|
|
81
81
|
[Schedulers and containers](schedulers.md)), and otherwise the machine's. A job that runs several
|
|
82
82
|
processes should give each its share, for example
|
|
83
83
|
`STREAM_LIMITS_MAX_THREADS=$(( NCPUS / 4 ))` for four.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
## How OpenMP threads wait
|
|
87
|
+
|
|
88
|
+
OpenMP threads that finish a parallel region either spin, waiting for the next one, or
|
|
89
|
+
sleep. Spinning is quicker to resume, but a program that makes many short parallel calls
|
|
90
|
+
(numba's `prange` loops, one per batch of data) can run several times slower with it,
|
|
91
|
+
notably on CPUs that mix performance and efficiency cores, and a little slower on others.
|
|
92
|
+
Programs with few, long parallel regions are unaffected: hdfe-stream's solvers and
|
|
93
|
+
logit-stream's fits ran as fast either way.
|
|
94
|
+
|
|
95
|
+
{py:func}`stream_limits.set_openmp_wait_policy` sets `OMP_WAIT_POLICY` (to `"passive"` unless you
|
|
96
|
+
say `"active"`). A package opts in by calling it, because the setting reaches every
|
|
97
|
+
OpenMP library in the process (BLAS builds on OpenMP, scikit-learn, LightGBM); importing
|
|
98
|
+
stream-limits, and `apply_scheduler_defaults`, leave it alone. A value you have set is kept unless
|
|
99
|
+
you pass `override=True`. The runtime reads the variable when it loads, which for numba is
|
|
100
|
+
at its first parallel call, so call the function before that. The result says when a runtime
|
|
101
|
+
was already loaded (Linux), in which case the variable is set for child processes only.
|
|
102
|
+
On Windows, numba uses Microsoft's OpenMP runtime, which does not read the variable, so the
|
|
103
|
+
call changes nothing there.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stream-limits"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -87,10 +87,11 @@ import warnings
|
|
|
87
87
|
from pathlib import Path
|
|
88
88
|
from typing import Iterator, Mapping
|
|
89
89
|
|
|
90
|
-
__version__ = "0.
|
|
90
|
+
__version__ = "0.3.0"
|
|
91
91
|
|
|
92
92
|
__all__ = [
|
|
93
93
|
"set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
|
|
94
|
+
"set_openmp_wait_policy",
|
|
94
95
|
"detect_cpus", "detect_memory_gb", "describe", "MemoryBudget",
|
|
95
96
|
"peak_rss_gb", "reset_peak_rss", "current_rss_gb",
|
|
96
97
|
]
|
|
@@ -790,13 +791,59 @@ def thread_scope(n: int | None = None) -> Iterator[None]:
|
|
|
790
791
|
numba.set_num_threads(previous)
|
|
791
792
|
|
|
792
793
|
|
|
794
|
+
def _openmp_loaded() -> bool | None:
|
|
795
|
+
"""Whether an OpenMP runtime is already in this process (Linux only; None
|
|
796
|
+
elsewhere). It reads ``OMP_WAIT_POLICY`` once, when it loads."""
|
|
797
|
+
try:
|
|
798
|
+
maps = Path("/proc/self/maps").read_text()
|
|
799
|
+
except OSError:
|
|
800
|
+
return None
|
|
801
|
+
return any(name in maps for name in ("libgomp", "libiomp5", "libomp"))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def set_openmp_wait_policy(policy: str = "passive", *, override: bool = False,
|
|
805
|
+
environ=None) -> dict:
|
|
806
|
+
"""Set how OpenMP threads wait between parallel regions, by ``OMP_WAIT_POLICY``.
|
|
807
|
+
|
|
808
|
+
With ``"passive"`` an idle thread sleeps until the next region; with
|
|
809
|
+
``"active"`` it spins. A package whose compiled code makes many short
|
|
810
|
+
parallel calls (numba's ``prange`` loops, one per batch of data) can run
|
|
811
|
+
several times slower with spinning threads, notably on CPUs with
|
|
812
|
+
performance and efficiency cores, and a little slower on others. Code with
|
|
813
|
+
few, long parallel regions (hdfe-stream's solvers, logit-stream's fits)
|
|
814
|
+
is unaffected, and this function is not called by importing the package:
|
|
815
|
+
a package opts in, since the setting reaches every OpenMP library in the
|
|
816
|
+
process (BLAS builds on OpenMP, scikit-learn, LightGBM). Microsoft's OpenMP
|
|
817
|
+
runtime, the one numba uses on Windows, does not read the variable, so there
|
|
818
|
+
the call changes nothing.
|
|
819
|
+
|
|
820
|
+
A value the user has set is left alone unless `override`. The runtime reads
|
|
821
|
+
the variable once, when it loads (numba loads its OpenMP layer at the first
|
|
822
|
+
parallel call), so call this before the first parallel call; the result's
|
|
823
|
+
``"late"`` is True when a runtime was already loaded (Linux), in which case
|
|
824
|
+
the variable is still set, for child processes, but this process is
|
|
825
|
+
unaffected. Returns ``{"policy": ..., "set": bool, "late": bool | None}``,
|
|
826
|
+
where ``"policy"`` is the value in effect."""
|
|
827
|
+
if policy not in ("passive", "active"):
|
|
828
|
+
raise ValueError(f"policy must be 'passive' or 'active', not {policy!r}")
|
|
829
|
+
environ = os.environ if environ is None else environ
|
|
830
|
+
current = environ.get("OMP_WAIT_POLICY")
|
|
831
|
+
if current and not override:
|
|
832
|
+
return {"policy": current.lower(), "set": False, "late": None}
|
|
833
|
+
late = _openmp_loaded() if environ is os.environ else None
|
|
834
|
+
environ["OMP_WAIT_POLICY"] = policy
|
|
835
|
+
return {"policy": policy, "set": True, "late": late}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
|
|
793
839
|
# ----------------------------------------------------- peak memory used
|
|
794
|
-
def _windows_memory() -> tuple[int, int] | None:
|
|
840
|
+
def _windows_memory(handle=None) -> tuple[int, int] | None:
|
|
795
841
|
"""This process's ``(working set, peak working set)`` in bytes, from the
|
|
796
842
|
Windows API (``GetProcessMemoryInfo``), or None: off Windows, or if the call
|
|
797
843
|
fails. The working set is the physical memory resident in the process, which
|
|
798
844
|
is what ``VmRSS`` is on Linux, and the peak is a high-water mark the system
|
|
799
|
-
keeps.
|
|
845
|
+
keeps. `handle`: another process's handle instead (the launcher reads a
|
|
846
|
+
child's peak after it exits, while its handle is still open)."""
|
|
800
847
|
if sys.platform != "win32":
|
|
801
848
|
return None
|
|
802
849
|
try:
|
|
@@ -821,8 +868,8 @@ def _windows_memory() -> tuple[int, int] | None:
|
|
|
821
868
|
kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
|
|
822
869
|
counters = _Counters()
|
|
823
870
|
counters.cb = ctypes.sizeof(_Counters)
|
|
824
|
-
|
|
825
|
-
|
|
871
|
+
target = kernel32.GetCurrentProcess() if handle is None else handle
|
|
872
|
+
if not kernel32.K32GetProcessMemoryInfo(target, ctypes.byref(counters), counters.cb):
|
|
826
873
|
return None
|
|
827
874
|
return int(counters.WorkingSetSize), int(counters.PeakWorkingSetSize)
|
|
828
875
|
except Exception: # noqa: BLE001
|
|
@@ -18,7 +18,7 @@ import time
|
|
|
18
18
|
from collections.abc import Mapping, Sequence
|
|
19
19
|
from dataclasses import asdict, dataclass
|
|
20
20
|
|
|
21
|
-
from . import _THREAD_ENV
|
|
21
|
+
from . import _THREAD_ENV, _windows_memory
|
|
22
22
|
from .enforcement import MemoryCgroup, UnsupportedEnforcement
|
|
23
23
|
|
|
24
24
|
|
|
@@ -39,13 +39,23 @@ class RunResult:
|
|
|
39
39
|
descendants_terminated: bool
|
|
40
40
|
rss_sampling_complete: bool
|
|
41
41
|
os_peak_rss_bytes: int | None = None
|
|
42
|
+
peak_private_bytes: int | None = None
|
|
43
|
+
memory_measure: str = "rss"
|
|
42
44
|
|
|
43
45
|
# os_peak_rss_bytes: the largest resident set any single process reached,
|
|
44
46
|
# as the OS records it. POSIX: the child's ru_maxrss from wait4, which
|
|
45
47
|
# covers the child and the descendants it waited for (exact, not sampled;
|
|
46
48
|
# not summed over the tree; background processes it left running are not
|
|
47
|
-
# included). Windows: the
|
|
48
|
-
#
|
|
49
|
+
# included). Windows: the child's peak working set, read from its handle
|
|
50
|
+
# after it exits (not its descendants'; the largest sampled value if that
|
|
51
|
+
# read fails). None when unavailable. Like RSS, it counts pages of
|
|
52
|
+
# memory-mapped files.
|
|
53
|
+
#
|
|
54
|
+
# peak_private_bytes: the largest sampled total over the process tree of
|
|
55
|
+
# memory that is not a file's pages (see _private_bytes). None on macOS.
|
|
56
|
+
#
|
|
57
|
+
# memory_measure: what the watchdog compared with the limit, "private" or
|
|
58
|
+
# "rss" (asked for, or private memory is unavailable on this platform).
|
|
49
59
|
|
|
50
60
|
def as_dict(self):
|
|
51
61
|
return asdict(self)
|
|
@@ -58,6 +68,7 @@ def capabilities():
|
|
|
58
68
|
"psutil_available": importlib.util.find_spec("psutil") is not None,
|
|
59
69
|
"monitor": True,
|
|
60
70
|
"watchdog": True,
|
|
71
|
+
"private_memory": private_memory_available(),
|
|
61
72
|
"hard_backends": ["cgroup-v1", "cgroup-v2"] if sys.platform == "linux" else [],
|
|
62
73
|
"hard_limit_accounting": "cgroup-charged memory"
|
|
63
74
|
if sys.platform == "linux"
|
|
@@ -78,6 +89,36 @@ def thread_env(threads, env=None):
|
|
|
78
89
|
return out
|
|
79
90
|
|
|
80
91
|
|
|
92
|
+
def _private_bytes(process, info):
|
|
93
|
+
"""Memory of one process that is not a file's pages, which the kernel can
|
|
94
|
+
drop and read back (memory-mapped scratch files, mapped libraries).
|
|
95
|
+
Linux: anonymous plus shared memory (RssAnon + RssShmem; shared memory
|
|
96
|
+
is not reclaimable without swap), else RSS less psutil's shared pages.
|
|
97
|
+
Windows: private bytes (committed memory, so it can exceed RSS). None
|
|
98
|
+
elsewhere (macOS has no cheap equivalent)."""
|
|
99
|
+
if sys.platform.startswith("linux"):
|
|
100
|
+
try:
|
|
101
|
+
found = {}
|
|
102
|
+
with open(f"/proc/{process.pid}/status", encoding="ascii") as fh:
|
|
103
|
+
for line in fh:
|
|
104
|
+
if line.startswith(("RssAnon:", "RssShmem:")):
|
|
105
|
+
key, value = line.split()[:2]
|
|
106
|
+
found[key] = int(value) * 1024
|
|
107
|
+
if "RssAnon:" in found:
|
|
108
|
+
return found["RssAnon:"] + found.get("RssShmem:", 0)
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
pass
|
|
111
|
+
return max(0, info.rss - getattr(info, "shared", 0))
|
|
112
|
+
if hasattr(info, "private"):
|
|
113
|
+
return info.private
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def private_memory_available():
|
|
118
|
+
"""Whether this platform reports private memory (Linux, Windows)."""
|
|
119
|
+
return sys.platform.startswith("linux") or os.name == "nt"
|
|
120
|
+
|
|
121
|
+
|
|
81
122
|
def _positive(value, name):
|
|
82
123
|
if (
|
|
83
124
|
isinstance(value, bool)
|
|
@@ -101,13 +142,20 @@ def run_limited(
|
|
|
101
142
|
max_output_bytes=1_000_000,
|
|
102
143
|
threads: int | None = None,
|
|
103
144
|
capture_output: bool = True,
|
|
145
|
+
memory_measure: str = "private",
|
|
104
146
|
):
|
|
105
147
|
"""Run a command without a shell and return measurements and termination cause.
|
|
106
148
|
|
|
107
149
|
hard: verified Linux cgroup cap, including descendants; no automatic fallback.
|
|
108
|
-
watchdog: sampled process-tree
|
|
150
|
+
watchdog: sampled process-tree memory threshold, which can overshoot.
|
|
109
151
|
monitor: measurements/timeouts only, without a memory ceiling.
|
|
110
152
|
|
|
153
|
+
memory_measure: what the watchdog compares with the limit. "private"
|
|
154
|
+
(default) leaves out pages of files, which the kernel can reclaim, so a
|
|
155
|
+
program that memory-maps large scratch files is not stopped for them;
|
|
156
|
+
where private memory is unavailable (macOS) RSS is used, and the result's
|
|
157
|
+
memory_measure says so. "rss" counts every resident page.
|
|
158
|
+
|
|
111
159
|
GB is decimal. The supervisor stays outside the child's hard cap. env has
|
|
112
160
|
subprocess replacement semantics; no budget/thread variables are injected
|
|
113
161
|
unless `threads` is given, which sets them all (`thread_env`). Captured
|
|
@@ -125,6 +173,13 @@ def run_limited(
|
|
|
125
173
|
)
|
|
126
174
|
if mode not in ("hard", "watchdog", "monitor"):
|
|
127
175
|
raise ValueError("mode must be hard, watchdog or monitor")
|
|
176
|
+
if memory_measure not in ("private", "rss"):
|
|
177
|
+
raise ValueError("memory_measure must be private or rss")
|
|
178
|
+
measure = (
|
|
179
|
+
"private"
|
|
180
|
+
if memory_measure == "private" and private_memory_available()
|
|
181
|
+
else "rss"
|
|
182
|
+
)
|
|
128
183
|
if memory_gb is not None:
|
|
129
184
|
_positive(memory_gb, "memory_gb")
|
|
130
185
|
if mode != "monitor" and memory_gb is None:
|
|
@@ -158,6 +213,7 @@ def run_limited(
|
|
|
158
213
|
group = MemoryCgroup(cgroup_parent, limit) if mode == "hard" else None
|
|
159
214
|
proc, kernel, tracked = None, None, {}
|
|
160
215
|
reason, peak, descendants = None, 0, False
|
|
216
|
+
private_peak = 0 if private_memory_available() else None
|
|
161
217
|
os_peak, stopped = None, False
|
|
162
218
|
sampling_complete = True
|
|
163
219
|
started = time.monotonic()
|
|
@@ -216,6 +272,12 @@ def run_limited(
|
|
|
216
272
|
if proc.poll() is None:
|
|
217
273
|
proc.kill()
|
|
218
274
|
proc.wait()
|
|
275
|
+
# The exited process's counters stay readable while Popen holds
|
|
276
|
+
# its handle: the exact peak working set, not the last sample.
|
|
277
|
+
handle = getattr(proc, "_handle", None)
|
|
278
|
+
counters = _windows_memory(int(handle)) if handle is not None else None
|
|
279
|
+
if counters is not None:
|
|
280
|
+
os_peak = max(os_peak or 0, counters[1])
|
|
219
281
|
|
|
220
282
|
def tail(handle):
|
|
221
283
|
size = handle.seek(0, os.SEEK_END)
|
|
@@ -239,7 +301,7 @@ def run_limited(
|
|
|
239
301
|
except psutil.NoSuchProcess:
|
|
240
302
|
process = None
|
|
241
303
|
while not exited():
|
|
242
|
-
rss = 0
|
|
304
|
+
rss, private = 0, 0
|
|
243
305
|
try:
|
|
244
306
|
members = (
|
|
245
307
|
[process, *process.children(recursive=True)]
|
|
@@ -250,6 +312,8 @@ def run_limited(
|
|
|
250
312
|
try:
|
|
251
313
|
info = p.memory_info()
|
|
252
314
|
rss += info.rss
|
|
315
|
+
if private_peak is not None:
|
|
316
|
+
private += _private_bytes(p, info) or 0
|
|
253
317
|
if hasattr(info, "peak_wset"): # Windows
|
|
254
318
|
os_peak = max(os_peak or 0, info.peak_wset)
|
|
255
319
|
if p.pid != proc.pid:
|
|
@@ -263,10 +327,13 @@ def run_limited(
|
|
|
263
327
|
except psutil.AccessDenied:
|
|
264
328
|
sampling_complete = False
|
|
265
329
|
peak = max(peak, rss)
|
|
330
|
+
if private_peak is not None:
|
|
331
|
+
private_peak = max(private_peak, private)
|
|
266
332
|
if mode == "watchdog" and not sampling_complete:
|
|
267
333
|
reason = "monitoring_failed"
|
|
268
334
|
break
|
|
269
|
-
if
|
|
335
|
+
watched = private if measure == "private" else rss
|
|
336
|
+
if mode == "watchdog" and watched > limit:
|
|
270
337
|
reason = "rss_limit_exceeded"
|
|
271
338
|
break
|
|
272
339
|
if timeout is not None and time.monotonic() - started >= timeout:
|
|
@@ -316,6 +383,8 @@ def run_limited(
|
|
|
316
383
|
descendants,
|
|
317
384
|
sampling_complete,
|
|
318
385
|
os_peak_rss_bytes=os_peak,
|
|
386
|
+
peak_private_bytes=private_peak,
|
|
387
|
+
memory_measure=measure,
|
|
319
388
|
)
|
|
320
389
|
finally:
|
|
321
390
|
try:
|
|
@@ -343,6 +412,12 @@ def main():
|
|
|
343
412
|
help="let the command write to this terminal; the JSON summary goes to stderr",
|
|
344
413
|
)
|
|
345
414
|
parser.add_argument("--report", help="also write the JSON summary to this file")
|
|
415
|
+
parser.add_argument(
|
|
416
|
+
"--memory-measure",
|
|
417
|
+
choices=["private", "rss"],
|
|
418
|
+
default="private",
|
|
419
|
+
help="what the watchdog limits: private memory (default; not file pages) or RSS",
|
|
420
|
+
)
|
|
346
421
|
parser.add_argument("--capabilities", action="store_true")
|
|
347
422
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
348
423
|
args = parser.parse_args()
|
|
@@ -359,6 +434,7 @@ def main():
|
|
|
359
434
|
timeout=args.timeout,
|
|
360
435
|
threads=args.threads,
|
|
361
436
|
capture_output=not args.no_capture,
|
|
437
|
+
memory_measure=args.memory_measure,
|
|
362
438
|
)
|
|
363
439
|
except (ValueError, OSError, RuntimeError, ImportError) as exc:
|
|
364
440
|
parser.error(str(exc))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stream-limits
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
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
5
|
Author: Lee Tucker
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,7 +65,11 @@ def test_watchdog_and_timeout_have_distinct_causes():
|
|
|
65
65
|
timeout=10,
|
|
66
66
|
)
|
|
67
67
|
assert result.status == "rss_limit_exceeded" and result.returncode != 0
|
|
68
|
-
|
|
68
|
+
# what the watchdog limited: on Windows private bytes count the bytearray
|
|
69
|
+
# once committed, before its pages are resident
|
|
70
|
+
watched = (result.peak_private_bytes if result.memory_measure == "private"
|
|
71
|
+
else result.peak_rss_bytes)
|
|
72
|
+
assert watched > 64_000_000
|
|
69
73
|
result = run_limited(
|
|
70
74
|
python("import time; time.sleep(5)"), mode="monitor", timeout=0.2
|
|
71
75
|
)
|
|
@@ -247,8 +251,6 @@ def test_os_peak_is_exact_and_covers_short_commands():
|
|
|
247
251
|
assert result.status == "ok"
|
|
248
252
|
assert result.os_peak_rss_bytes is not None
|
|
249
253
|
assert result.os_peak_rss_bytes >= 200_000_000
|
|
250
|
-
if os.name != "posix": # Windows reads its peak while sampling
|
|
251
|
-
return
|
|
252
254
|
# too short for any sample: the sampled peak can be 0, the OS's is not
|
|
253
255
|
quick = run_limited(python("pass"), mode="monitor", timeout=30)
|
|
254
256
|
assert quick.os_peak_rss_bytes is not None and quick.os_peak_rss_bytes > 0
|
|
@@ -328,3 +330,65 @@ def test_cli_without_capture_passes_output_through(tmp_path):
|
|
|
328
330
|
summary = json.loads(result.stderr)
|
|
329
331
|
assert summary["status"] == "ok" and summary["stdout"] == ""
|
|
330
332
|
assert json.loads(report.read_text()) == summary
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# A program that writes a 600 MB file and memory-maps it, touching every page:
|
|
336
|
+
# its RSS grows by the file's size, its private memory does not.
|
|
337
|
+
MAPPED = """
|
|
338
|
+
import mmap, os, sys
|
|
339
|
+
path = sys.argv[1]
|
|
340
|
+
with open(path, "wb") as fh:
|
|
341
|
+
fh.truncate(600_000_000)
|
|
342
|
+
with open(path, "r+b") as fh:
|
|
343
|
+
m = mmap.mmap(fh.fileno(), 0)
|
|
344
|
+
for i in range(0, len(m), 4096):
|
|
345
|
+
m[i] = 1
|
|
346
|
+
m.flush()
|
|
347
|
+
total = sum(m[i] for i in range(0, len(m), 4096))
|
|
348
|
+
import time; time.sleep(0.3)
|
|
349
|
+
print(total)
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
|
|
354
|
+
def test_watchdog_ignores_mapped_file_pages_by_default(tmp_path):
|
|
355
|
+
command = [sys.executable, "-c", MAPPED, str(tmp_path / "scratch.bin")]
|
|
356
|
+
result = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60)
|
|
357
|
+
assert result.status == "ok", result.as_dict()
|
|
358
|
+
assert result.memory_measure == "private"
|
|
359
|
+
assert result.peak_private_bytes < 200_000_000
|
|
360
|
+
assert result.peak_rss_bytes > 400_000_000 # the mapped pages are resident
|
|
361
|
+
rss = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60,
|
|
362
|
+
memory_measure="rss")
|
|
363
|
+
assert rss.status == "rss_limit_exceeded" and rss.memory_measure == "rss"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
|
|
367
|
+
def test_watchdog_stops_private_memory_over_the_limit():
|
|
368
|
+
code = "import time; x=bytearray(500_000_000); x[::4096]=b'1'*len(x[::4096]); time.sleep(5)"
|
|
369
|
+
result = run_limited(python(code), mode="watchdog", memory_gb=0.3, timeout=30)
|
|
370
|
+
assert result.status == "rss_limit_exceeded" and result.memory_measure == "private"
|
|
371
|
+
assert result.peak_private_bytes > 300_000_000
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux /proc")
|
|
375
|
+
def test_shared_memory_counts_as_private():
|
|
376
|
+
# shared memory (/dev/shm, multiprocessing) cannot be reclaimed without swap
|
|
377
|
+
code = ("from multiprocessing import shared_memory as s; import time; "
|
|
378
|
+
"m=s.SharedMemory(create=True,size=300_000_000); m.buf[::4096]=b'1'*len(m.buf[::4096]); "
|
|
379
|
+
"time.sleep(0.5); m.close(); m.unlink()")
|
|
380
|
+
result = run_limited(python(code), mode="monitor", timeout=30)
|
|
381
|
+
assert result.status == "ok" and result.peak_private_bytes > 250_000_000
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_private_measure_is_reported_or_falls_back():
|
|
385
|
+
from stream_limits.launcher import private_memory_available
|
|
386
|
+
|
|
387
|
+
result = run_limited(python("import time; time.sleep(0.3)"), mode="watchdog",
|
|
388
|
+
memory_gb=1, timeout=30)
|
|
389
|
+
if private_memory_available():
|
|
390
|
+
assert result.memory_measure == "private" and result.peak_private_bytes > 0
|
|
391
|
+
else:
|
|
392
|
+
assert result.memory_measure == "rss" and result.peak_private_bytes is None
|
|
393
|
+
with pytest.raises(ValueError):
|
|
394
|
+
run_limited(python("pass"), mode="monitor", memory_measure="uss")
|
|
@@ -213,3 +213,68 @@ def test_a_preset_spill_dir_is_kept_unless_override(tmp_path):
|
|
|
213
213
|
a = _run(code.format(extra=""), POLARS_OOC_SPILL_DIR="/somewhere/else")
|
|
214
214
|
b = _run(code.format(extra=", override=True"), POLARS_OOC_SPILL_DIR="/somewhere/else")
|
|
215
215
|
assert a.stdout.strip() == "/somewhere/else" and b.stdout.strip() == str(tmp_path), (a.stderr, b.stderr)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --- set_openmp_wait_policy ---
|
|
219
|
+
|
|
220
|
+
def test_wait_policy_sets_the_variable_and_respects_the_user():
|
|
221
|
+
env = {}
|
|
222
|
+
assert sl.set_openmp_wait_policy(environ=env) == {"policy": "passive", "set": True, "late": None}
|
|
223
|
+
assert env["OMP_WAIT_POLICY"] == "passive"
|
|
224
|
+
env = {"OMP_WAIT_POLICY": "ACTIVE"}
|
|
225
|
+
assert sl.set_openmp_wait_policy(environ=env) == {"policy": "active", "set": False, "late": None}
|
|
226
|
+
assert env["OMP_WAIT_POLICY"] == "ACTIVE"
|
|
227
|
+
assert sl.set_openmp_wait_policy(environ=env, override=True)["set"] is True
|
|
228
|
+
assert env["OMP_WAIT_POLICY"] == "passive"
|
|
229
|
+
with pytest.raises(ValueError):
|
|
230
|
+
sl.set_openmp_wait_policy("sleepy", environ={})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_importing_the_module_leaves_the_policy_alone():
|
|
234
|
+
r = _run("import os, stream_limits; print(os.environ.get('OMP_WAIT_POLICY'))")
|
|
235
|
+
assert r.stdout.strip() == "None"
|
|
236
|
+
r = _run("import os, stream_limits; stream_limits.apply_scheduler_defaults(); "
|
|
237
|
+
"print(os.environ.get('OMP_WAIT_POLICY'))", NCPUS="2")
|
|
238
|
+
assert r.stdout.strip() == "None"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
NUMBA_PROBE = """
|
|
242
|
+
import numpy as np, sys
|
|
243
|
+
{before}
|
|
244
|
+
import numba
|
|
245
|
+
from numba import njit, prange
|
|
246
|
+
import stream_limits
|
|
247
|
+
print(stream_limits.set_openmp_wait_policy()["late"])
|
|
248
|
+
@njit(parallel=True)
|
|
249
|
+
def f(a):
|
|
250
|
+
s = 0.0
|
|
251
|
+
for i in prange(a.size):
|
|
252
|
+
s += a[i]
|
|
253
|
+
return s
|
|
254
|
+
f(np.ones(100))
|
|
255
|
+
{after}
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_wait_policy_takes_effect_when_numba_is_imported_first():
|
|
260
|
+
# numba loads its OpenMP runtime at the first parallel call, which is after
|
|
261
|
+
r = _run(NUMBA_PROBE.format(before="", after="print(numba.threading_layer())"),
|
|
262
|
+
OMP_DISPLAY_ENV="verbose")
|
|
263
|
+
if "omp" not in r.stdout.split():
|
|
264
|
+
pytest.skip("numba's OpenMP threading layer is not in use")
|
|
265
|
+
# "late" is only detectable on Linux (it reads /proc/self/maps)
|
|
266
|
+
assert r.stdout.split()[0] == ("False" if sys.platform.startswith("linux") else "None")
|
|
267
|
+
if sys.platform == "win32":
|
|
268
|
+
pytest.skip("Microsoft's OpenMP runtime does not read or display OMP_WAIT_POLICY")
|
|
269
|
+
assert "OMP_WAIT_POLICY = 'PASSIVE'" in r.stderr
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_wait_policy_reports_a_runtime_that_is_already_loaded():
|
|
273
|
+
r = _run(NUMBA_PROBE.format(before="", after="") +
|
|
274
|
+
"\nimport stream_limits as s\nprint(s.set_openmp_wait_policy(override=True)['late'])")
|
|
275
|
+
if not sys.platform.startswith("linux"):
|
|
276
|
+
pytest.skip("detection needs /proc/self/maps")
|
|
277
|
+
lines = r.stdout.split()
|
|
278
|
+
if lines[-1] == "False":
|
|
279
|
+
pytest.skip("numba's OpenMP layer is not in use")
|
|
280
|
+
assert lines[-1] == "True"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|