stream-limits 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stream_limits-0.1.0 → stream_limits-0.2.0}/CHANGELOG.md +27 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/CITATION.cff +2 -2
- {stream_limits-0.1.0 → stream_limits-0.2.0}/PKG-INFO +27 -6
- {stream_limits-0.1.0 → stream_limits-0.2.0}/README.md +20 -5
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/index.md +12 -0
- stream_limits-0.2.0/docs/launcher.md +236 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/memory.md +2 -1
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/reference.md +10 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/threads.md +2 -2
- {stream_limits-0.1.0 → stream_limits-0.2.0}/pyproject.toml +5 -2
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/__init__.py +54 -7
- stream_limits-0.2.0/stream_limits/_exec_cgroup.py +18 -0
- stream_limits-0.2.0/stream_limits/enforcement.py +199 -0
- stream_limits-0.2.0/stream_limits/launcher.py +376 -0
- stream_limits-0.2.0/stream_limits/pytest_plugin.py +76 -0
- stream_limits-0.2.0/stream_limits/testing.py +64 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/PKG-INFO +27 -6
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/SOURCES.txt +8 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/requires.txt +8 -0
- stream_limits-0.2.0/tests/conftest.py +1 -0
- stream_limits-0.2.0/tests/test_launcher.py +330 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/tests/test_limits.py +35 -3
- {stream_limits-0.1.0 → stream_limits-0.2.0}/LICENSE.md +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/MANIFEST.in +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/_static/.gitkeep +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/changelog.md +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/conf.py +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/other-libraries.md +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/schedulers.md +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/setup.cfg +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/__main__.py +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/py.typed +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/dependency_links.txt +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/top_level.txt +0 -0
- {stream_limits-0.1.0 → stream_limits-0.2.0}/tests/test_override_and_libraries.py +0 -0
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- **Run a program under a memory limit and see how much memory it used.** The new
|
|
6
|
+
launcher runs a script, test suite or other program in a child process, stops it
|
|
7
|
+
if it goes over a memory limit or a time limit, and reports why it ended and its
|
|
8
|
+
peak memory. The limit is a watchdog that checks memory every 10 ms (Linux,
|
|
9
|
+
macOS, Windows), or a kernel limit through a Linux cgroup. It also sets the
|
|
10
|
+
program's threads (`--threads`). It is an optional extra,
|
|
11
|
+
`pip install 'stream-limits[launcher]'`, which adds psutil; the rest of
|
|
12
|
+
stream-limits is unchanged and still needs only the standard library. See
|
|
13
|
+
[Running a program under a memory limit](https://leetucker.github.io/stream-limits/launcher.html).
|
|
14
|
+
- **Pytest fixtures for memory tests.** `pytest_plugins =
|
|
15
|
+
["stream_limits.pytest_plugin"]` gives a test suite `resource_runner`, which runs
|
|
16
|
+
a test's work in a child process under the watchdog on every platform, or under a
|
|
17
|
+
kernel limit when one is set up (`STREAM_LIMITS_TEST_CGROUP_PARENT`). Install
|
|
18
|
+
`stream-limits[testing]`.
|
|
19
|
+
|
|
20
|
+
## 0.1.1
|
|
21
|
+
|
|
22
|
+
- **Windows reports memory use.** `peak_rss_gb` and `current_rss_gb` returned `None` on Windows in
|
|
23
|
+
0.1.0; they now read the process's peak and current working set (the physical memory resident in it,
|
|
24
|
+
as on Linux) through the Windows API, with no new dependency. Windows cannot reset its peak, so
|
|
25
|
+
there it is the process's lifetime peak, as on macOS. `MemoryBudget` uses the real current memory
|
|
26
|
+
on Windows instead of assuming 0.5 GB.
|
|
27
|
+
- Documentation: Polars 1.37 creates its thread pool at first use, while later versions (1.44 and
|
|
28
|
+
2.x) create it at import; the thread-limit pages say so.
|
|
29
|
+
|
|
3
30
|
## 0.1.0
|
|
4
31
|
|
|
5
32
|
First release, coinciding with updates to hdfe-stream, logit-stream and
|
|
@@ -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-
|
|
10
|
+
version: 0.2.0
|
|
11
|
+
date-released: 2026-11-04
|
|
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.2.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
|
|
@@ -26,6 +26,11 @@ Classifier: Typing :: Typed
|
|
|
26
26
|
Requires-Python: >=3.10
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE.md
|
|
29
|
+
Provides-Extra: launcher
|
|
30
|
+
Requires-Dist: psutil>=5.9; extra == "launcher"
|
|
31
|
+
Provides-Extra: testing
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == "testing"
|
|
33
|
+
Requires-Dist: psutil>=5.9; extra == "testing"
|
|
29
34
|
Provides-Extra: blas
|
|
30
35
|
Requires-Dist: threadpoolctl>=3.1; extra == "blas"
|
|
31
36
|
Provides-Extra: docs
|
|
@@ -35,6 +40,7 @@ Requires-Dist: furo>=2024.8.6; extra == "docs"
|
|
|
35
40
|
Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
|
|
36
41
|
Provides-Extra: test
|
|
37
42
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
43
|
+
Requires-Dist: psutil>=5.9; extra == "test"
|
|
38
44
|
Requires-Dist: numpy; extra == "test"
|
|
39
45
|
Requires-Dist: numba; extra == "test"
|
|
40
46
|
Requires-Dist: polars; extra == "test"
|
|
@@ -85,7 +91,7 @@ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia
|
|
|
85
91
|
|
|
86
92
|
| Library | Can it be capped after import? | What `limit_threads` does |
|
|
87
93
|
|---|---|---|
|
|
88
|
-
| Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
94
|
+
| Polars | **No**: the pool is sized once, at import (Polars 1.37: at first use) | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
89
95
|
| numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
|
|
90
96
|
| BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
|
|
91
97
|
| pyarrow | yes | `set_cpu_count`, `set_io_thread_count` |
|
|
@@ -112,8 +118,8 @@ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia
|
|
|
112
118
|
(default 15%, `STREAM_LIMITS_MEMORY_RESERVE`) for what the code does not model, less what the
|
|
113
119
|
process holds now. Ask whether your exact allocation fits, not whether it is under a fixed
|
|
114
120
|
fraction of the machine.
|
|
115
|
-
- `peak_rss_gb()` / `reset_peak_rss()` read the
|
|
116
|
-
polling.
|
|
121
|
+
- `peak_rss_gb()` / `reset_peak_rss()` read the system's own high-water mark (Linux, macOS and
|
|
122
|
+
Windows): free, no polling.
|
|
117
123
|
|
|
118
124
|
`set_limits` is `limit_threads` and `limit_memory` in one call: values you give are applied, values you
|
|
119
125
|
leave out are the job's allocation if one is found and nothing otherwise. The two remain, for their
|
|
@@ -152,8 +158,23 @@ pip install stream-limits # nothing else is installed
|
|
|
152
158
|
pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
|
|
153
159
|
```
|
|
154
160
|
|
|
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
|
-
|
|
161
|
+
Linux and macOS are tested on Python 3.10 to 3.15, Windows on 3.10 and 3.14. On Windows the module imports,
|
|
162
|
+
applies thread limits and reads memory use (peak and current working set), but it detects no cgroup;
|
|
163
|
+
macOS and Windows report a lifetime peak, as their counters cannot be reset. On macOS, NumPy's Apple
|
|
157
164
|
Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
|
|
158
165
|
|
|
166
|
+
## Running a program under a memory limit
|
|
167
|
+
|
|
168
|
+
The optional launcher (`pip install 'stream-limits[launcher]'`, which adds psutil)
|
|
169
|
+
runs a program in a child process, stops it if it goes over a memory or time
|
|
170
|
+
limit, and reports why it ended and its peak memory:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python -m stream_limits.launcher --mode watchdog --memory-gb 4 --threads 2 -- python analysis.py
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The watchdog works on Linux, macOS and Windows; a kernel-enforced limit needs a
|
|
177
|
+
Linux cgroup set up for it. `stream-limits[testing]` adds pytest fixtures for
|
|
178
|
+
memory tests. See [Running a program under a memory limit](docs/launcher.md).
|
|
179
|
+
|
|
159
180
|
MIT licence.
|
|
@@ -40,7 +40,7 @@ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia
|
|
|
40
40
|
|
|
41
41
|
| Library | Can it be capped after import? | What `limit_threads` does |
|
|
42
42
|
|---|---|---|
|
|
43
|
-
| Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
43
|
+
| Polars | **No**: the pool is sized once, at import (Polars 1.37: at first use) | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
44
44
|
| numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
|
|
45
45
|
| BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
|
|
46
46
|
| pyarrow | yes | `set_cpu_count`, `set_io_thread_count` |
|
|
@@ -67,8 +67,8 @@ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia
|
|
|
67
67
|
(default 15%, `STREAM_LIMITS_MEMORY_RESERVE`) for what the code does not model, less what the
|
|
68
68
|
process holds now. Ask whether your exact allocation fits, not whether it is under a fixed
|
|
69
69
|
fraction of the machine.
|
|
70
|
-
- `peak_rss_gb()` / `reset_peak_rss()` read the
|
|
71
|
-
polling.
|
|
70
|
+
- `peak_rss_gb()` / `reset_peak_rss()` read the system's own high-water mark (Linux, macOS and
|
|
71
|
+
Windows): free, no polling.
|
|
72
72
|
|
|
73
73
|
`set_limits` is `limit_threads` and `limit_memory` in one call: values you give are applied, values you
|
|
74
74
|
leave out are the job's allocation if one is found and nothing otherwise. The two remain, for their
|
|
@@ -107,8 +107,23 @@ pip install stream-limits # nothing else is installed
|
|
|
107
107
|
pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
Linux and macOS are tested on Python 3.10 to 3.15, Windows on 3.10 and 3.14. On Windows the module imports
|
|
111
|
-
|
|
110
|
+
Linux and macOS are tested on Python 3.10 to 3.15, Windows on 3.10 and 3.14. On Windows the module imports,
|
|
111
|
+
applies thread limits and reads memory use (peak and current working set), but it detects no cgroup;
|
|
112
|
+
macOS and Windows report a lifetime peak, as their counters cannot be reset. On macOS, NumPy's Apple
|
|
112
113
|
Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
|
|
113
114
|
|
|
115
|
+
## Running a program under a memory limit
|
|
116
|
+
|
|
117
|
+
The optional launcher (`pip install 'stream-limits[launcher]'`, which adds psutil)
|
|
118
|
+
runs a program in a child process, stops it if it goes over a memory or time
|
|
119
|
+
limit, and reports why it ended and its peak memory:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python -m stream_limits.launcher --mode watchdog --memory-gb 4 --threads 2 -- python analysis.py
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The watchdog works on Linux, macOS and Windows; a kernel-enforced limit needs a
|
|
126
|
+
Linux cgroup set up for it. `stream-limits[testing]` adds pytest fixtures for
|
|
127
|
+
memory tests. See [Running a program under a memory limit](docs/launcher.md).
|
|
128
|
+
|
|
114
129
|
MIT licence.
|
|
@@ -82,3 +82,15 @@ other-libraries
|
|
|
82
82
|
reference
|
|
83
83
|
changelog
|
|
84
84
|
```
|
|
85
|
+
|
|
86
|
+
## Running a program under a memory limit
|
|
87
|
+
|
|
88
|
+
The optional [launcher](launcher.md) runs a program in a child process, stops it
|
|
89
|
+
if it goes over a memory or time limit, and reports its peak memory. It also
|
|
90
|
+
gives other packages' test suites fixtures for memory tests.
|
|
91
|
+
|
|
92
|
+
```{toctree}
|
|
93
|
+
:hidden:
|
|
94
|
+
|
|
95
|
+
launcher
|
|
96
|
+
```
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Running a program under a memory limit
|
|
2
|
+
|
|
3
|
+
The launcher runs a program (a Python or R script, a test suite, any executable)
|
|
4
|
+
in a child process, watches its memory, and stops it if it goes over a limit you
|
|
5
|
+
set. When the program ends it tells you why it ended and how much memory it used.
|
|
6
|
+
|
|
7
|
+
It is an optional extra, which adds psutil and nothing else:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install 'stream-limits[launcher]'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Nothing else in stream-limits changes: `import stream_limits` still needs only
|
|
14
|
+
the standard library, and the launcher imports no numerical library itself.
|
|
15
|
+
|
|
16
|
+
## A first example
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m stream_limits.launcher --mode watchdog --memory-gb 4 --threads 2 \
|
|
20
|
+
--timeout 600 -- python analysis.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This runs `python analysis.py` with 2 threads for every numerical library, stops
|
|
24
|
+
it if it and the processes it starts use more than 4 GB together or run longer
|
|
25
|
+
than 10 minutes, and prints a JSON summary: the status, the exit code, the time,
|
|
26
|
+
the peak memory, and the end of the program's output. The exit code is the
|
|
27
|
+
program's own (124 on a timeout).
|
|
28
|
+
|
|
29
|
+
The same from Python:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import sys
|
|
33
|
+
from stream_limits.launcher import run_limited
|
|
34
|
+
|
|
35
|
+
result = run_limited([sys.executable, "analysis.py"],
|
|
36
|
+
mode="watchdog", memory_gb=4, threads=2, timeout=600)
|
|
37
|
+
print(result.status, result.os_peak_rss_bytes / 1e9, "GB")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The command is a list of arguments, run without a shell. GB are decimal
|
|
41
|
+
(1 GB = 10^9 bytes).
|
|
42
|
+
|
|
43
|
+
## Three ways to limit
|
|
44
|
+
|
|
45
|
+
| Mode | Platforms | What it does |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `watchdog` | Linux, macOS, Windows | Checks memory about every 10 ms and stops the program when it passes the limit |
|
|
48
|
+
| `hard` | Linux, with a cgroup set up for it | The kernel enforces the limit |
|
|
49
|
+
| `monitor` | Linux, macOS, Windows | No limit: measures, with an optional timeout |
|
|
50
|
+
|
|
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. Because it samples,
|
|
53
|
+
it can overshoot a little before it acts, and a peak shorter than the interval
|
|
54
|
+
can slip between samples. In practice it is tight: on Linux, with a 1.5 GB limit,
|
|
55
|
+
a fast 3 GB NumPy allocation, a 2.5 GB spike lasting 20 ms and a grandchild
|
|
56
|
+
process allocating 3 GB were each stopped at 1.51-1.52 GB. Memory shared
|
|
57
|
+
between processes can be counted more than once. If the operating system denies
|
|
58
|
+
it access to a process's memory, the watchdog stops the program and reports
|
|
59
|
+
`monitoring_failed` rather than claim it enforced the limit.
|
|
60
|
+
|
|
61
|
+
**Hard limits** leave nothing to sampling: the kernel will not let the program
|
|
62
|
+
use more, and kills it if it tries. They need a Linux cgroup that you are
|
|
63
|
+
allowed to manage (below). `hard` is the default mode, so that asking for a hard
|
|
64
|
+
limit where none is possible fails before the program starts, instead of quietly
|
|
65
|
+
running under the watchdog. macOS and Windows have no hard mode.
|
|
66
|
+
|
|
67
|
+
## Results
|
|
68
|
+
|
|
69
|
+
`run_limited` returns a `RunResult` (`as_dict()` turns it into JSON). Its
|
|
70
|
+
`status` is one of:
|
|
71
|
+
|
|
72
|
+
| Status | Meaning |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `ok` | The program finished with exit code 0 |
|
|
75
|
+
| `command_failed` | It finished with another exit code |
|
|
76
|
+
| `timeout` | It ran past `timeout` and was stopped |
|
|
77
|
+
| `rss_limit_exceeded` | The watchdog stopped it |
|
|
78
|
+
| `monitoring_failed` | The watchdog could not read its memory and stopped it |
|
|
79
|
+
| `kernel_oom` | The kernel killed a process for going over a hard limit |
|
|
80
|
+
|
|
81
|
+
`kernel_oom` comes from the kernel's own counter, not from guessing at a killed
|
|
82
|
+
process. It is reported even when the process killed was a child the program
|
|
83
|
+
started and the program itself carried on and exited with 0, so check `status`
|
|
84
|
+
and not only the exit code.
|
|
85
|
+
|
|
86
|
+
Two peaks are reported:
|
|
87
|
+
|
|
88
|
+
- `os_peak_rss_bytes`: the largest memory of any single process, as the
|
|
89
|
+
operating system records it. On Linux and macOS it is exact, and covers the
|
|
90
|
+
program and every process it started and waited for (not one it left running
|
|
91
|
+
in the background). On Windows it is read while sampling. Use it when a test
|
|
92
|
+
needs a reliable peak.
|
|
93
|
+
- `peak_rss_bytes`: the largest sampled total over all the program's processes
|
|
94
|
+
at once. Use it for a program with several worker processes. A program that
|
|
95
|
+
ends before the first sample reports 0.
|
|
96
|
+
|
|
97
|
+
With a hard limit, `result.kernel` adds the kernel's own figures: the limit, any
|
|
98
|
+
smaller limit set higher up, its peak (which counts file cache and kernel
|
|
99
|
+
memory, so it differs from the two above), and its event counters.
|
|
100
|
+
|
|
101
|
+
## Output, threads and the environment
|
|
102
|
+
|
|
103
|
+
By default the program's output is saved to temporary files and the last
|
|
104
|
+
megabyte of each stream is returned in the result (`max_output_bytes=`). To see
|
|
105
|
+
it as it runs, as when wrapping a whole test run, pass `--no-capture`
|
|
106
|
+
(`capture_output=False`). The program then writes to the terminal and the JSON
|
|
107
|
+
summary goes to stderr; `--report FILE` also saves the summary to a file:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python -m stream_limits.launcher --mode watchdog --memory-gb 4 --threads 2 \
|
|
111
|
+
--no-capture --report memory.json -- python -m pytest -q
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--threads N` (`threads=N`) sets `STREAM_LIMITS_MAX_THREADS` and the thread
|
|
115
|
+
variables of Polars, numba, OpenMP, the BLAS libraries, numexpr, rayon and Julia
|
|
116
|
+
in the program's environment. `stream_limits.launcher.thread_env(N, env)`
|
|
117
|
+
returns that environment. Without `threads`, no variable is added: `env`
|
|
118
|
+
replaces the environment as in `subprocess`, and the launcher's own environment
|
|
119
|
+
is never changed.
|
|
120
|
+
|
|
121
|
+
On a timeout or when a limit is hit, the program and the processes it started
|
|
122
|
+
are stopped. On Linux and macOS the launcher also stops anything left in the
|
|
123
|
+
program's process group, and with a hard limit anything left in its cgroup. On
|
|
124
|
+
Windows, a process that escapes before it is first sampled can survive.
|
|
125
|
+
|
|
126
|
+
## Setting up hard limits
|
|
127
|
+
|
|
128
|
+
Hard limits use a Linux cgroup with the memory controller, which an
|
|
129
|
+
administrator (or you, with root) sets up once and lets you manage. The launcher
|
|
130
|
+
never uses sudo and never changes a cgroup it did not create: for each run it
|
|
131
|
+
creates a fresh group inside the one you give it, sets the limit there, starts
|
|
132
|
+
the program inside it (the launcher itself stays outside the limit), and removes
|
|
133
|
+
the group afterwards. It is a resource limit, not a security sandbox.
|
|
134
|
+
|
|
135
|
+
`python -m stream_limits.launcher --capabilities` and `cat /proc/self/cgroup`
|
|
136
|
+
show what the machine has.
|
|
137
|
+
|
|
138
|
+
**cgroup v1** (WSL2, older distributions), once, as root:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
sudo mkdir /sys/fs/cgroup/memory/stream-limits-test
|
|
142
|
+
sudo chown -R "$USER" /sys/fs/cgroup/memory/stream-limits-test
|
|
143
|
+
export STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup/memory/stream-limits-test
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The group is gone after a reboot.
|
|
147
|
+
|
|
148
|
+
**cgroup v2 with systemd**: start a shell in a user scope that you may manage:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
systemd-run --user --scope -p Delegate=yes -p MemoryMax=8G bash
|
|
152
|
+
export STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
On v2 the group must have `memory` in its `cgroup.subtree_control` and no
|
|
156
|
+
processes of its own, so the shell may first need to move into a child group.
|
|
157
|
+
|
|
158
|
+
These recipes are standard, but stream-limits' tests do not run them (they need
|
|
159
|
+
root or a systemd scope). Check a setup before relying on it:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python -c "from stream_limits.testing import verify_memory_limit as v; v('$STREAM_LIMITS_TEST_CGROUP_PARENT')"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This runs a small allocation that must succeed, checks that a program inside the
|
|
166
|
+
group sees the limit, and runs a 256 MiB allocation in a 64 MiB group that the
|
|
167
|
+
kernel must kill. It raises unless all three happen. (The group needs room for
|
|
168
|
+
64 MiB.)
|
|
169
|
+
|
|
170
|
+
Then:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
result = run_limited([sys.executable, "analysis.py"], mode="hard", memory_gb=4,
|
|
174
|
+
cgroup_parent="/sys/fs/cgroup/memory/stream-limits-test")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Before running anything, the launcher checks that the group really is a kernel
|
|
178
|
+
cgroup (not an ordinary directory with the same file names) and reads the limit
|
|
179
|
+
back. It turns swap off for the group (v2: `memory.swap.max=0`; v1: a
|
|
180
|
+
memory-plus-swap limit equal to the memory limit and `swappiness=0`), so the
|
|
181
|
+
limit cannot be dodged by swapping. On v2 the kernel stops the whole group
|
|
182
|
+
together when one process goes over. Older v2 kernels have no peak counter;
|
|
183
|
+
their kernel peak is then `None`.
|
|
184
|
+
|
|
185
|
+
The launcher's tests have exercised hard limits on a WSL2 host with cgroup v1.
|
|
186
|
+
cgroup v2, macOS and Windows rely on CI and have not been checked by hand.
|
|
187
|
+
|
|
188
|
+
## Memory tests in pytest
|
|
189
|
+
|
|
190
|
+
The plugin gives other packages' test suites a common way to run a test's work in
|
|
191
|
+
a child process under a memory limit. Install `stream-limits[testing]` and
|
|
192
|
+
register it in `conftest.py` (it is not loaded automatically):
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
pytest_plugins = ["stream_limits.pytest_plugin"]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A memory test then looks like this:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import sys
|
|
202
|
+
import pytest
|
|
203
|
+
|
|
204
|
+
@pytest.mark.resource
|
|
205
|
+
def test_fit_stays_within_memory(resource_runner, tmp_path):
|
|
206
|
+
result = resource_runner([sys.executable, "fit_large_case.py", str(tmp_path)],
|
|
207
|
+
memory_gb=1.0, threads=2, timeout=120)
|
|
208
|
+
assert result.status == "ok", result.as_dict()
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`resource_runner` takes `run_limited`'s arguments except the mode. It uses the
|
|
212
|
+
watchdog on every platform, so these tests run on macOS and Windows too. When
|
|
213
|
+
`STREAM_LIMITS_TEST_CGROUP_PARENT` names a cgroup set up as above, it checks it
|
|
214
|
+
once per session with `verify_memory_limit` and uses a hard limit instead; a
|
|
215
|
+
setting that does not work fails the session rather than falling back.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pytest -m resource # watchdog
|
|
219
|
+
STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup/memory/stream-limits-test pytest -m resource
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The other fixtures: `resource_enforcement` says which mode was chosen
|
|
223
|
+
(`{"mode": "watchdog"}` or `{"mode": "hard", "cgroup_parent": ...}`);
|
|
224
|
+
`memory_runner` is `run_limited` itself, for a test that picks its own mode; and
|
|
225
|
+
`verified_memory_parent` is the checked cgroup, for tests that need a hard limit
|
|
226
|
+
and skip without one. The plugin registers the markers `resource` and
|
|
227
|
+
`kernel_memory`.
|
|
228
|
+
|
|
229
|
+
**A memory test is only as good as its input.** A child process that imports
|
|
230
|
+
Polars, NumPy and numba already holds 0.2-0.4 GB, so a small input under a 1.5 GB
|
|
231
|
+
limit passes whatever the code does with it. To catch code that holds the data in
|
|
232
|
+
memory, give the test an input larger in memory than the limit less that
|
|
233
|
+
baseline, or compare `os_peak_rss_bytes` at N rows and at several times N.
|
|
234
|
+
|
|
235
|
+
Use the launcher only for tests about memory. Ordinary tests are better run in
|
|
236
|
+
the test process, as before.
|
|
@@ -91,5 +91,6 @@ print(stream_limits.peak_rss_gb()) # GB, the peak during the call
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
On Linux the counter is reset by writing to `/proc/self/clear_refs`, so a second measurement in
|
|
94
|
-
the same process reports its own peak;
|
|
94
|
+
the same process reports its own peak; on macOS and Windows the system's counter cannot be
|
|
95
|
+
reset, so it is the process's lifetime peak (a second measurement is the largest of both). Child
|
|
95
96
|
processes are not included. {py:func}`stream_limits.current_rss_gb` is the memory held now.
|
|
@@ -65,3 +65,13 @@
|
|
|
65
65
|
`NUMEXPR_NUM_THREADS`, `NUMEXPR_MAX_THREADS`, `RAYON_NUM_THREADS`, `TF_NUM_INTRAOP_THREADS`,
|
|
66
66
|
`TF_NUM_INTEROP_THREADS`, `LOKY_MAX_CPU_COUNT`, `JULIA_NUM_THREADS`. `limit_memory` sets
|
|
67
67
|
`POLARS_OOC_MEMORY_BUDGET_MB` and, if asked, `POLARS_OOC_SPILL_DIR`.
|
|
68
|
+
|
|
69
|
+
## Optional launcher
|
|
70
|
+
|
|
71
|
+
```{eval-rst}
|
|
72
|
+
.. automodule:: stream_limits.launcher
|
|
73
|
+
:members: RunResult, run_limited, capabilities, thread_env
|
|
74
|
+
|
|
75
|
+
.. automodule:: stream_limits.testing
|
|
76
|
+
:members: verify_memory_limit, kernel_oom_proved
|
|
77
|
+
```
|
|
@@ -8,7 +8,7 @@ threads and be killed for it.
|
|
|
8
8
|
|
|
9
9
|
| Library | Can it be capped after import? | What `limit_threads` does |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
11
|
+
| Polars | **No**: the pool is sized once, at import (Polars 1.37: at first use) | sets `POLARS_MAX_THREADS` (before import); warns if too late |
|
|
12
12
|
| numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
|
|
13
13
|
| BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
|
|
14
14
|
| Apple Accelerate (NumPy on macOS) | **No**: `threadpoolctl` cannot see it | sets `VECLIB_MAXIMUM_THREADS` (before NumPy is imported) |
|
|
@@ -66,7 +66,7 @@ always wins over them.
|
|
|
66
66
|
## For one block of code
|
|
67
67
|
|
|
68
68
|
{py:func}`stream_limits.thread_scope` holds numba and BLAS to `n` threads inside a block and
|
|
69
|
-
restores them after, also when the block raises. Polars is not affected: its pool is fixed for
|
|
69
|
+
restores them after, also when the block raises. Polars is not affected: its pool is fixed (at import, or at first use in Polars 1.37) for
|
|
70
70
|
the process.
|
|
71
71
|
|
|
72
72
|
```python
|
|
@@ -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.2.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"
|
|
@@ -33,6 +33,9 @@ requires-python = ">=3.10"
|
|
|
33
33
|
dependencies = []
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
36
|
+
# Opt-in subprocess launcher; core installs remain standard-library-only.
|
|
37
|
+
launcher = ["psutil>=5.9"]
|
|
38
|
+
testing = ["pytest>=7.0", "psutil>=5.9"]
|
|
36
39
|
# threadpoolctl lets thread_scope()/limit_threads() limit BLAS after numpy is loaded
|
|
37
40
|
blas = ["threadpoolctl>=3.1"]
|
|
38
41
|
# docs/: the documentation site, built with Sphinx (see docs/conf.py)
|
|
@@ -42,7 +45,7 @@ docs = [
|
|
|
42
45
|
"furo>=2024.8.6",
|
|
43
46
|
"sphinx-copybutton>=0.5",
|
|
44
47
|
]
|
|
45
|
-
test = ["pytest>=7.0", "numpy", "numba", "polars", "pyarrow", "threadpoolctl>=3.1",
|
|
48
|
+
test = ["pytest>=7.0", "psutil>=5.9", "numpy", "numba", "polars", "pyarrow", "threadpoolctl>=3.1",
|
|
46
49
|
'numexpr; python_version < "3.15"']
|
|
47
50
|
|
|
48
51
|
[project.urls]
|
|
@@ -87,7 +87,7 @@ import warnings
|
|
|
87
87
|
from pathlib import Path
|
|
88
88
|
from typing import Iterator, Mapping
|
|
89
89
|
|
|
90
|
-
__version__ = "0.
|
|
90
|
+
__version__ = "0.2.0"
|
|
91
91
|
|
|
92
92
|
__all__ = [
|
|
93
93
|
"set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
|
|
@@ -791,10 +791,49 @@ def thread_scope(n: int | None = None) -> Iterator[None]:
|
|
|
791
791
|
|
|
792
792
|
|
|
793
793
|
# ----------------------------------------------------- peak memory used
|
|
794
|
+
def _windows_memory() -> tuple[int, int] | None:
|
|
795
|
+
"""This process's ``(working set, peak working set)`` in bytes, from the
|
|
796
|
+
Windows API (``GetProcessMemoryInfo``), or None: off Windows, or if the call
|
|
797
|
+
fails. The working set is the physical memory resident in the process, which
|
|
798
|
+
is what ``VmRSS`` is on Linux, and the peak is a high-water mark the system
|
|
799
|
+
keeps."""
|
|
800
|
+
if sys.platform != "win32":
|
|
801
|
+
return None
|
|
802
|
+
try:
|
|
803
|
+
import ctypes
|
|
804
|
+
from ctypes import wintypes
|
|
805
|
+
|
|
806
|
+
class _Counters(ctypes.Structure): # PROCESS_MEMORY_COUNTERS
|
|
807
|
+
_fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
|
|
808
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
809
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
810
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
811
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
812
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
813
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
814
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
815
|
+
("PeakPagefileUsage", ctypes.c_size_t)]
|
|
816
|
+
|
|
817
|
+
kernel32 = ctypes.WinDLL("kernel32")
|
|
818
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE # 64-bit safe
|
|
819
|
+
kernel32.K32GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(_Counters),
|
|
820
|
+
wintypes.DWORD]
|
|
821
|
+
kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
|
|
822
|
+
counters = _Counters()
|
|
823
|
+
counters.cb = ctypes.sizeof(_Counters)
|
|
824
|
+
if not kernel32.K32GetProcessMemoryInfo(kernel32.GetCurrentProcess(),
|
|
825
|
+
ctypes.byref(counters), counters.cb):
|
|
826
|
+
return None
|
|
827
|
+
return int(counters.WorkingSetSize), int(counters.PeakWorkingSetSize)
|
|
828
|
+
except Exception: # noqa: BLE001
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
|
|
794
832
|
def reset_peak_rss() -> None:
|
|
795
|
-
"""Restart the peak-memory counter at the current usage
|
|
796
|
-
|
|
797
|
-
peak
|
|
833
|
+
"""Restart the peak-memory counter at the current usage. Linux only: macOS
|
|
834
|
+
and Windows keep a counter that cannot be reset, so there `peak_rss_gb`
|
|
835
|
+
reports the process's lifetime peak (a second measurement in the same
|
|
836
|
+
process is the largest of both)."""
|
|
798
837
|
try:
|
|
799
838
|
with open("/proc/self/clear_refs", "w") as f:
|
|
800
839
|
f.write("5")
|
|
@@ -804,8 +843,12 @@ def reset_peak_rss() -> None:
|
|
|
804
843
|
|
|
805
844
|
def peak_rss_gb() -> float | None:
|
|
806
845
|
"""The peak resident memory, in GB, since `reset_peak_rss` (or since the
|
|
807
|
-
process started). The
|
|
808
|
-
nothing is polled
|
|
846
|
+
process started). The system keeps the counter, so reading it is free and
|
|
847
|
+
nothing is polled: ``VmHWM`` on Linux, ``getrusage`` on macOS, the peak
|
|
848
|
+
working set on Windows. Child processes are not included."""
|
|
849
|
+
win = _windows_memory()
|
|
850
|
+
if win is not None:
|
|
851
|
+
return win[1] / 1e9
|
|
809
852
|
try:
|
|
810
853
|
for line in Path("/proc/self/status").read_text().splitlines():
|
|
811
854
|
if line.startswith("VmHWM:"):
|
|
@@ -821,7 +864,11 @@ def peak_rss_gb() -> float | None:
|
|
|
821
864
|
|
|
822
865
|
|
|
823
866
|
def current_rss_gb() -> float | None:
|
|
824
|
-
"""The resident memory in GB right now (Linux; None
|
|
867
|
+
"""The resident memory in GB right now (Linux and Windows; None on macOS,
|
|
868
|
+
where `MemoryBudget` then assumes 0.5 GB)."""
|
|
869
|
+
win = _windows_memory()
|
|
870
|
+
if win is not None:
|
|
871
|
+
return win[0] / 1e9
|
|
825
872
|
try:
|
|
826
873
|
for line in Path("/proc/self/status").read_text().splitlines():
|
|
827
874
|
if line.startswith("VmRSS:"):
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Internal pre-exec attachment helper, deliberately standard-library-only."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
if len(sys.argv) < 4 or sys.argv[2] != "--":
|
|
10
|
+
raise SystemExit("internal usage: PARENT -- COMMAND ...")
|
|
11
|
+
path = Path(sys.argv[1])
|
|
12
|
+
(path / "cgroup.procs").write_text(str(os.getpid()))
|
|
13
|
+
command = sys.argv[3:]
|
|
14
|
+
os.execvpe(command[0], command, os.environ)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|