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.
Files changed (35) hide show
  1. {stream_limits-0.1.0 → stream_limits-0.2.0}/CHANGELOG.md +27 -0
  2. {stream_limits-0.1.0 → stream_limits-0.2.0}/CITATION.cff +2 -2
  3. {stream_limits-0.1.0 → stream_limits-0.2.0}/PKG-INFO +27 -6
  4. {stream_limits-0.1.0 → stream_limits-0.2.0}/README.md +20 -5
  5. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/index.md +12 -0
  6. stream_limits-0.2.0/docs/launcher.md +236 -0
  7. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/memory.md +2 -1
  8. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/reference.md +10 -0
  9. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/threads.md +2 -2
  10. {stream_limits-0.1.0 → stream_limits-0.2.0}/pyproject.toml +5 -2
  11. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/__init__.py +54 -7
  12. stream_limits-0.2.0/stream_limits/_exec_cgroup.py +18 -0
  13. stream_limits-0.2.0/stream_limits/enforcement.py +199 -0
  14. stream_limits-0.2.0/stream_limits/launcher.py +376 -0
  15. stream_limits-0.2.0/stream_limits/pytest_plugin.py +76 -0
  16. stream_limits-0.2.0/stream_limits/testing.py +64 -0
  17. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/PKG-INFO +27 -6
  18. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/SOURCES.txt +8 -0
  19. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/requires.txt +8 -0
  20. stream_limits-0.2.0/tests/conftest.py +1 -0
  21. stream_limits-0.2.0/tests/test_launcher.py +330 -0
  22. {stream_limits-0.1.0 → stream_limits-0.2.0}/tests/test_limits.py +35 -3
  23. {stream_limits-0.1.0 → stream_limits-0.2.0}/LICENSE.md +0 -0
  24. {stream_limits-0.1.0 → stream_limits-0.2.0}/MANIFEST.in +0 -0
  25. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/_static/.gitkeep +0 -0
  26. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/changelog.md +0 -0
  27. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/conf.py +0 -0
  28. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/other-libraries.md +0 -0
  29. {stream_limits-0.1.0 → stream_limits-0.2.0}/docs/schedulers.md +0 -0
  30. {stream_limits-0.1.0 → stream_limits-0.2.0}/setup.cfg +0 -0
  31. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/__main__.py +0 -0
  32. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits/py.typed +0 -0
  33. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/dependency_links.txt +0 -0
  34. {stream_limits-0.1.0 → stream_limits-0.2.0}/stream_limits.egg-info/top_level.txt +0 -0
  35. {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.1.0
11
- date-released: 2026-10-31
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.1.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 kernel's own high-water mark (Linux): free, no
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
- and applies thread limits, but it detects no cgroup and cannot read peak memory. On macOS, NumPy's Apple
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 kernel's own high-water mark (Linux): free, no
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
- and applies thread limits, but it detects no cgroup and cannot read peak memory. On macOS, NumPy's Apple
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; elsewhere it is the process's lifetime peak. Child
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.1.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.1.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 (Linux; elsewhere
796
- it does nothing, and `peak_rss_gb` then reports the process's lifetime
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 kernel keeps the counter, so reading it is free and
808
- nothing is polled. Child processes are not included."""
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 elsewhere)."""
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()