stream-limits 0.1.1__tar.gz → 0.2.1__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.1 → stream_limits-0.2.1}/CHANGELOG.md +29 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/CITATION.cff +2 -2
- {stream_limits-0.1.1 → stream_limits-0.2.1}/PKG-INFO +21 -1
- {stream_limits-0.1.1 → stream_limits-0.2.1}/README.md +14 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/index.md +12 -0
- stream_limits-0.2.1/docs/launcher.md +253 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/reference.md +10 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/pyproject.toml +5 -2
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits/__init__.py +6 -5
- stream_limits-0.2.1/stream_limits/_exec_cgroup.py +18 -0
- stream_limits-0.2.1/stream_limits/enforcement.py +199 -0
- stream_limits-0.2.1/stream_limits/launcher.py +452 -0
- stream_limits-0.2.1/stream_limits/pytest_plugin.py +76 -0
- stream_limits-0.2.1/stream_limits/testing.py +64 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits.egg-info/PKG-INFO +21 -1
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits.egg-info/SOURCES.txt +8 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits.egg-info/requires.txt +8 -0
- stream_limits-0.2.1/tests/conftest.py +1 -0
- stream_limits-0.2.1/tests/test_launcher.py +394 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/LICENSE.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/MANIFEST.in +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/_static/.gitkeep +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/changelog.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/conf.py +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/memory.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/other-libraries.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/schedulers.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/docs/threads.md +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/setup.cfg +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits/__main__.py +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits/py.typed +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits.egg-info/dependency_links.txt +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/stream_limits.egg-info/top_level.txt +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/tests/test_limits.py +0 -0
- {stream_limits-0.1.1 → stream_limits-0.2.1}/tests/test_override_and_libraries.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- **The watchdog no longer counts the pages of memory-mapped files.** A program
|
|
6
|
+
that memory-maps large scratch files, as hdfe-stream does, has those pages in
|
|
7
|
+
its resident memory, but the kernel can reclaim them, so a watchdog that
|
|
8
|
+
counted them stopped healthy programs: a 20-million-row hdfe-stream fit with
|
|
9
|
+
1.2 GB of its own memory reached 2.3 GB of RSS. The watchdog now limits
|
|
10
|
+
private memory (Linux, Windows; macOS still uses RSS), reported as
|
|
11
|
+
`peak_private_bytes`. `--memory-measure rss` restores the old behaviour.
|
|
12
|
+
- On Windows, `os_peak_rss_bytes` is now the program's exact peak working set,
|
|
13
|
+
read when it exits, instead of the last value seen while sampling.
|
|
14
|
+
|
|
15
|
+
## 0.2.0
|
|
16
|
+
|
|
17
|
+
- **Run a program under a memory limit and see how much memory it used.** The new
|
|
18
|
+
launcher runs a script, test suite or other program in a child process, stops it
|
|
19
|
+
if it goes over a memory limit or a time limit, and reports why it ended and its
|
|
20
|
+
peak memory. The limit is a watchdog that checks memory every 10 ms (Linux,
|
|
21
|
+
macOS, Windows), or a kernel limit through a Linux cgroup. It also sets the
|
|
22
|
+
program's threads (`--threads`). It is an optional extra,
|
|
23
|
+
`pip install 'stream-limits[launcher]'`, which adds psutil; the rest of
|
|
24
|
+
stream-limits is unchanged and still needs only the standard library. See
|
|
25
|
+
[Running a program under a memory limit](https://leetucker.github.io/stream-limits/launcher.html).
|
|
26
|
+
- **Pytest fixtures for memory tests.** `pytest_plugins =
|
|
27
|
+
["stream_limits.pytest_plugin"]` gives a test suite `resource_runner`, which runs
|
|
28
|
+
a test's work in a child process under the watchdog on every platform, or under a
|
|
29
|
+
kernel limit when one is set up (`STREAM_LIMITS_TEST_CGROUP_PARENT`). Install
|
|
30
|
+
`stream-limits[testing]`.
|
|
31
|
+
|
|
3
32
|
## 0.1.1
|
|
4
33
|
|
|
5
34
|
- **Windows reports memory use.** `peak_rss_gb` and `current_rss_gb` returned `None` on Windows in
|
|
@@ -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.1
|
|
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.1
|
|
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"
|
|
@@ -157,4 +163,18 @@ applies thread limits and reads memory use (peak and current working set), but i
|
|
|
157
163
|
macOS and Windows report a lifetime peak, as their counters cannot be reset. On macOS, NumPy's Apple
|
|
158
164
|
Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
|
|
159
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
|
+
|
|
160
180
|
MIT licence.
|
|
@@ -112,4 +112,18 @@ applies thread limits and reads memory use (peak and current working set), but i
|
|
|
112
112
|
macOS and Windows report a lifetime peak, as their counters cannot be reset. On macOS, NumPy's Apple
|
|
113
113
|
Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
|
|
114
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
|
+
|
|
115
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,253 @@
|
|
|
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. 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,
|
|
64
|
+
it can overshoot a little before it acts, and a peak shorter than the interval
|
|
65
|
+
can slip between samples. In practice it is tight: on Linux, with a 1.5 GB limit,
|
|
66
|
+
a fast 3 GB NumPy allocation, a 2.5 GB spike lasting 20 ms and a grandchild
|
|
67
|
+
process allocating 3 GB were each stopped at 1.51-1.52 GB. Memory shared
|
|
68
|
+
between processes can be counted more than once. If the operating system denies
|
|
69
|
+
it access to a process's memory, the watchdog stops the program and reports
|
|
70
|
+
`monitoring_failed` rather than claim it enforced the limit.
|
|
71
|
+
|
|
72
|
+
**Hard limits** leave nothing to sampling: the kernel will not let the program
|
|
73
|
+
use more, and kills it if it tries. They need a Linux cgroup that you are
|
|
74
|
+
allowed to manage (below). `hard` is the default mode, so that asking for a hard
|
|
75
|
+
limit where none is possible fails before the program starts, instead of quietly
|
|
76
|
+
running under the watchdog. macOS and Windows have no hard mode.
|
|
77
|
+
|
|
78
|
+
## Results
|
|
79
|
+
|
|
80
|
+
`run_limited` returns a `RunResult` (`as_dict()` turns it into JSON). Its
|
|
81
|
+
`status` is one of:
|
|
82
|
+
|
|
83
|
+
| Status | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `ok` | The program finished with exit code 0 |
|
|
86
|
+
| `command_failed` | It finished with another exit code |
|
|
87
|
+
| `timeout` | It ran past `timeout` and was stopped |
|
|
88
|
+
| `rss_limit_exceeded` | The watchdog stopped it (by the measure in `memory_measure`, despite the name) |
|
|
89
|
+
| `monitoring_failed` | The watchdog could not read its memory and stopped it |
|
|
90
|
+
| `kernel_oom` | The kernel killed a process for going over a hard limit |
|
|
91
|
+
|
|
92
|
+
`kernel_oom` comes from the kernel's own counter, not from guessing at a killed
|
|
93
|
+
process. It is reported even when the process killed was a child the program
|
|
94
|
+
started and the program itself carried on and exited with 0, so check `status`
|
|
95
|
+
and not only the exit code.
|
|
96
|
+
|
|
97
|
+
Three peaks are reported:
|
|
98
|
+
|
|
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.
|
|
110
|
+
|
|
111
|
+
With a hard limit, `result.kernel` adds the kernel's own figures: the limit, any
|
|
112
|
+
smaller limit set higher up, its peak (which counts file cache and kernel
|
|
113
|
+
memory, so it differs from the two above), and its event counters.
|
|
114
|
+
|
|
115
|
+
## Output, threads and the environment
|
|
116
|
+
|
|
117
|
+
By default the program's output is saved to temporary files and the last
|
|
118
|
+
megabyte of each stream is returned in the result (`max_output_bytes=`). To see
|
|
119
|
+
it as it runs, as when wrapping a whole test run, pass `--no-capture`
|
|
120
|
+
(`capture_output=False`). The program then writes to the terminal and the JSON
|
|
121
|
+
summary goes to stderr; `--report FILE` also saves the summary to a file:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m stream_limits.launcher --mode watchdog --memory-gb 4 --threads 2 \
|
|
125
|
+
--no-capture --report memory.json -- python -m pytest -q
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`--threads N` (`threads=N`) sets `STREAM_LIMITS_MAX_THREADS` and the thread
|
|
129
|
+
variables of Polars, numba, OpenMP, the BLAS libraries, numexpr, rayon and Julia
|
|
130
|
+
in the program's environment. `stream_limits.launcher.thread_env(N, env)`
|
|
131
|
+
returns that environment. Without `threads`, no variable is added: `env`
|
|
132
|
+
replaces the environment as in `subprocess`, and the launcher's own environment
|
|
133
|
+
is never changed.
|
|
134
|
+
|
|
135
|
+
On a timeout or when a limit is hit, the program and the processes it started
|
|
136
|
+
are stopped. On Linux and macOS the launcher also stops anything left in the
|
|
137
|
+
program's process group, and with a hard limit anything left in its cgroup. On
|
|
138
|
+
Windows, a process that escapes before it is first sampled can survive.
|
|
139
|
+
|
|
140
|
+
## Setting up hard limits
|
|
141
|
+
|
|
142
|
+
Hard limits use a Linux cgroup with the memory controller, which an
|
|
143
|
+
administrator (or you, with root) sets up once and lets you manage. The launcher
|
|
144
|
+
never uses sudo and never changes a cgroup it did not create: for each run it
|
|
145
|
+
creates a fresh group inside the one you give it, sets the limit there, starts
|
|
146
|
+
the program inside it (the launcher itself stays outside the limit), and removes
|
|
147
|
+
the group afterwards. It is a resource limit, not a security sandbox.
|
|
148
|
+
|
|
149
|
+
`python -m stream_limits.launcher --capabilities` and `cat /proc/self/cgroup`
|
|
150
|
+
show what the machine has.
|
|
151
|
+
|
|
152
|
+
**cgroup v1** (WSL2, older distributions), once, as root:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
sudo mkdir /sys/fs/cgroup/memory/stream-limits-test
|
|
156
|
+
sudo chown -R "$USER" /sys/fs/cgroup/memory/stream-limits-test
|
|
157
|
+
export STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup/memory/stream-limits-test
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The group is gone after a reboot.
|
|
161
|
+
|
|
162
|
+
**cgroup v2 with systemd**: start a shell in a user scope that you may manage:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
systemd-run --user --scope -p Delegate=yes -p MemoryMax=8G bash
|
|
166
|
+
export STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On v2 the group must have `memory` in its `cgroup.subtree_control` and no
|
|
170
|
+
processes of its own, so the shell may first need to move into a child group.
|
|
171
|
+
|
|
172
|
+
These recipes are standard, but stream-limits' tests do not run them (they need
|
|
173
|
+
root or a systemd scope). Check a setup before relying on it:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
python -c "from stream_limits.testing import verify_memory_limit as v; v('$STREAM_LIMITS_TEST_CGROUP_PARENT')"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This runs a small allocation that must succeed, checks that a program inside the
|
|
180
|
+
group sees the limit, and runs a 256 MiB allocation in a 64 MiB group that the
|
|
181
|
+
kernel must kill. It raises unless all three happen. (The group needs room for
|
|
182
|
+
64 MiB.)
|
|
183
|
+
|
|
184
|
+
Then:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
result = run_limited([sys.executable, "analysis.py"], mode="hard", memory_gb=4,
|
|
188
|
+
cgroup_parent="/sys/fs/cgroup/memory/stream-limits-test")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Before running anything, the launcher checks that the group really is a kernel
|
|
192
|
+
cgroup (not an ordinary directory with the same file names) and reads the limit
|
|
193
|
+
back. It turns swap off for the group (v2: `memory.swap.max=0`; v1: a
|
|
194
|
+
memory-plus-swap limit equal to the memory limit and `swappiness=0`), so the
|
|
195
|
+
limit cannot be dodged by swapping. On v2 the kernel stops the whole group
|
|
196
|
+
together when one process goes over. Older v2 kernels have no peak counter;
|
|
197
|
+
their kernel peak is then `None`.
|
|
198
|
+
|
|
199
|
+
The launcher's tests have exercised hard limits on a WSL2 host with cgroup v1.
|
|
200
|
+
cgroup v2, macOS and Windows rely on CI and have not been checked by hand.
|
|
201
|
+
|
|
202
|
+
## Memory tests in pytest
|
|
203
|
+
|
|
204
|
+
The plugin gives other packages' test suites a common way to run a test's work in
|
|
205
|
+
a child process under a memory limit. Install `stream-limits[testing]` and
|
|
206
|
+
register it in `conftest.py` (it is not loaded automatically):
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
pytest_plugins = ["stream_limits.pytest_plugin"]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A memory test then looks like this:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import sys
|
|
216
|
+
import pytest
|
|
217
|
+
|
|
218
|
+
@pytest.mark.resource
|
|
219
|
+
def test_fit_stays_within_memory(resource_runner, tmp_path):
|
|
220
|
+
result = resource_runner([sys.executable, "fit_large_case.py", str(tmp_path)],
|
|
221
|
+
memory_gb=1.0, threads=2, timeout=120)
|
|
222
|
+
assert result.status == "ok", result.as_dict()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`resource_runner` takes `run_limited`'s arguments except the mode. It uses the
|
|
226
|
+
watchdog on every platform, so these tests run on macOS and Windows too. When
|
|
227
|
+
`STREAM_LIMITS_TEST_CGROUP_PARENT` names a cgroup set up as above, it checks it
|
|
228
|
+
once per session with `verify_memory_limit` and uses a hard limit instead; a
|
|
229
|
+
setting that does not work fails the session rather than falling back.
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pytest -m resource # watchdog
|
|
233
|
+
STREAM_LIMITS_TEST_CGROUP_PARENT=/sys/fs/cgroup/memory/stream-limits-test pytest -m resource
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The other fixtures: `resource_enforcement` says which mode was chosen
|
|
237
|
+
(`{"mode": "watchdog"}` or `{"mode": "hard", "cgroup_parent": ...}`);
|
|
238
|
+
`memory_runner` is `run_limited` itself, for a test that picks its own mode; and
|
|
239
|
+
`verified_memory_parent` is the checked cgroup, for tests that need a hard limit
|
|
240
|
+
and skip without one. The plugin registers the markers `resource` and
|
|
241
|
+
`kernel_memory`.
|
|
242
|
+
|
|
243
|
+
**A memory test is only as good as its input.** A child process that imports
|
|
244
|
+
Polars, NumPy and numba already holds 0.2-0.4 GB, so a small input under a 1.5 GB
|
|
245
|
+
limit passes whatever the code does with it. To catch code that holds the data in
|
|
246
|
+
memory, give the test an input larger in memory than the limit less that
|
|
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.
|
|
251
|
+
|
|
252
|
+
Use the launcher only for tests about memory. Ordinary tests are better run in
|
|
253
|
+
the test process, as before.
|
|
@@ -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
|
+
```
|
|
@@ -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.1"
|
|
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.1"
|
|
91
91
|
|
|
92
92
|
__all__ = [
|
|
93
93
|
"set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
|
|
@@ -791,12 +791,13 @@ 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:
|
|
794
|
+
def _windows_memory(handle=None) -> tuple[int, int] | None:
|
|
795
795
|
"""This process's ``(working set, peak working set)`` in bytes, from the
|
|
796
796
|
Windows API (``GetProcessMemoryInfo``), or None: off Windows, or if the call
|
|
797
797
|
fails. The working set is the physical memory resident in the process, which
|
|
798
798
|
is what ``VmRSS`` is on Linux, and the peak is a high-water mark the system
|
|
799
|
-
keeps.
|
|
799
|
+
keeps. `handle`: another process's handle instead (the launcher reads a
|
|
800
|
+
child's peak after it exits, while its handle is still open)."""
|
|
800
801
|
if sys.platform != "win32":
|
|
801
802
|
return None
|
|
802
803
|
try:
|
|
@@ -821,8 +822,8 @@ def _windows_memory() -> tuple[int, int] | None:
|
|
|
821
822
|
kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
|
|
822
823
|
counters = _Counters()
|
|
823
824
|
counters.cb = ctypes.sizeof(_Counters)
|
|
824
|
-
|
|
825
|
-
|
|
825
|
+
target = kernel32.GetCurrentProcess() if handle is None else handle
|
|
826
|
+
if not kernel32.K32GetProcessMemoryInfo(target, ctypes.byref(counters), counters.cb):
|
|
826
827
|
return None
|
|
827
828
|
return int(counters.WorkingSetSize), int(counters.PeakWorkingSetSize)
|
|
828
829
|
except Exception: # noqa: BLE001
|
|
@@ -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()
|