stream-limits 0.2.0__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.2.0 → stream_limits-0.2.1}/CHANGELOG.md +12 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/CITATION.cff +1 -1
- {stream_limits-0.2.0 → stream_limits-0.2.1}/PKG-INFO +1 -1
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/launcher.md +29 -12
- {stream_limits-0.2.0 → stream_limits-0.2.1}/pyproject.toml +1 -1
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/__init__.py +6 -5
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/launcher.py +82 -6
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/PKG-INFO +1 -1
- {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_launcher.py +67 -3
- {stream_limits-0.2.0 → stream_limits-0.2.1}/LICENSE.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/MANIFEST.in +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/README.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/_static/.gitkeep +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/changelog.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/conf.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/index.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/memory.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/other-libraries.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/reference.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/schedulers.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/threads.md +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/setup.cfg +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/__main__.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/_exec_cgroup.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/enforcement.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/py.typed +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/pytest_plugin.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/testing.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/SOURCES.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/dependency_links.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/requires.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/top_level.txt +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/conftest.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_limits.py +0 -0
- {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_override_and_libraries.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.2.0
|
|
4
16
|
|
|
5
17
|
- **Run a program under a memory limit and see how much memory it used.** The new
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stream-limits
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -49,7 +49,18 @@ The command is a list of arguments, run without a shell. GB are decimal
|
|
|
49
49
|
| `monitor` | Linux, macOS, Windows | No limit: measures, with an optional timeout |
|
|
50
50
|
|
|
51
51
|
**The watchdog** adds up the memory of the program and every process it has
|
|
52
|
-
started, and stops them all when the total passes the limit.
|
|
52
|
+
started, and stops them all when the total passes the limit. It counts
|
|
53
|
+
*private* memory: memory that is not the pages of a file. A program that
|
|
54
|
+
memory-maps a large file (a scratch array on disk, a mapped Parquet file) has
|
|
55
|
+
the file's pages in its resident memory (RSS), but the kernel can drop them and
|
|
56
|
+
read them back when memory runs short, so they are not counted against the
|
|
57
|
+
limit. hdfe-stream is such a program: on a 20-million-row panel its RSS reached
|
|
58
|
+
2.3 GB while its private memory stayed at 1.2 GB, the same as on 2.6 million
|
|
59
|
+
rows. On Linux private memory is anonymous plus shared memory; on Windows it is
|
|
60
|
+
private bytes (committed memory, which can be more than RSS). macOS has no cheap
|
|
61
|
+
equivalent, so there the watchdog counts RSS. `--memory-measure rss`
|
|
62
|
+
(`memory_measure="rss"`) counts every resident page on every platform, and the
|
|
63
|
+
result's `memory_measure` says which was used. Because it samples,
|
|
53
64
|
it can overshoot a little before it acts, and a peak shorter than the interval
|
|
54
65
|
can slip between samples. In practice it is tight: on Linux, with a 1.5 GB limit,
|
|
55
66
|
a fast 3 GB NumPy allocation, a 2.5 GB spike lasting 20 ms and a grandchild
|
|
@@ -74,7 +85,7 @@ running under the watchdog. macOS and Windows have no hard mode.
|
|
|
74
85
|
| `ok` | The program finished with exit code 0 |
|
|
75
86
|
| `command_failed` | It finished with another exit code |
|
|
76
87
|
| `timeout` | It ran past `timeout` and was stopped |
|
|
77
|
-
| `rss_limit_exceeded` | The watchdog stopped it |
|
|
88
|
+
| `rss_limit_exceeded` | The watchdog stopped it (by the measure in `memory_measure`, despite the name) |
|
|
78
89
|
| `monitoring_failed` | The watchdog could not read its memory and stopped it |
|
|
79
90
|
| `kernel_oom` | The kernel killed a process for going over a hard limit |
|
|
80
91
|
|
|
@@ -83,16 +94,19 @@ process. It is reported even when the process killed was a child the program
|
|
|
83
94
|
started and the program itself carried on and exited with 0, so check `status`
|
|
84
95
|
and not only the exit code.
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
Three peaks are reported:
|
|
87
98
|
|
|
88
|
-
- `
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
- `peak_private_bytes`: the largest sampled total of private memory over all the
|
|
100
|
+
program's processes (`None` on macOS). This is the one to use for a program
|
|
101
|
+
that memory-maps files, and the one the watchdog limits.
|
|
102
|
+
- `os_peak_rss_bytes`: the largest RSS of any single process, as the operating
|
|
103
|
+
system records it. On Linux and macOS it is exact, and covers the program and
|
|
104
|
+
every process it started and waited for (not one it left running in the
|
|
105
|
+
background). On Windows it is the program's own peak working set, read when
|
|
106
|
+
it exits (not its child processes'). It counts mapped file pages. Use it when a test needs a reliable peak of a program that maps no
|
|
107
|
+
large files, or on macOS.
|
|
108
|
+
- `peak_rss_bytes`: the largest sampled total RSS over all the program's
|
|
109
|
+
processes at once. A program that ends before the first sample reports 0.
|
|
96
110
|
|
|
97
111
|
With a hard limit, `result.kernel` adds the kernel's own figures: the limit, any
|
|
98
112
|
smaller limit set higher up, its peak (which counts file cache and kernel
|
|
@@ -230,7 +244,10 @@ and skip without one. The plugin registers the markers `resource` and
|
|
|
230
244
|
Polars, NumPy and numba already holds 0.2-0.4 GB, so a small input under a 1.5 GB
|
|
231
245
|
limit passes whatever the code does with it. To catch code that holds the data in
|
|
232
246
|
memory, give the test an input larger in memory than the limit less that
|
|
233
|
-
baseline, or compare
|
|
247
|
+
baseline, or compare peaks at N rows and at several times N:
|
|
248
|
+
`peak_private_bytes` where it is available, since a program that memory-maps
|
|
249
|
+
its scratch files has an RSS that grows with the data even when its private
|
|
250
|
+
memory does not.
|
|
234
251
|
|
|
235
252
|
Use the launcher only for tests about memory. Ordinary tests are better run in
|
|
236
253
|
the test process, as before.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stream-limits"
|
|
7
|
-
version = "0.2.
|
|
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"
|
|
@@ -87,7 +87,7 @@ import warnings
|
|
|
87
87
|
from pathlib import Path
|
|
88
88
|
from typing import Iterator, Mapping
|
|
89
89
|
|
|
90
|
-
__version__ = "0.2.
|
|
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
|
|
@@ -18,7 +18,7 @@ import time
|
|
|
18
18
|
from collections.abc import Mapping, Sequence
|
|
19
19
|
from dataclasses import asdict, dataclass
|
|
20
20
|
|
|
21
|
-
from . import _THREAD_ENV
|
|
21
|
+
from . import _THREAD_ENV, _windows_memory
|
|
22
22
|
from .enforcement import MemoryCgroup, UnsupportedEnforcement
|
|
23
23
|
|
|
24
24
|
|
|
@@ -39,13 +39,23 @@ class RunResult:
|
|
|
39
39
|
descendants_terminated: bool
|
|
40
40
|
rss_sampling_complete: bool
|
|
41
41
|
os_peak_rss_bytes: int | None = None
|
|
42
|
+
peak_private_bytes: int | None = None
|
|
43
|
+
memory_measure: str = "rss"
|
|
42
44
|
|
|
43
45
|
# os_peak_rss_bytes: the largest resident set any single process reached,
|
|
44
46
|
# as the OS records it. POSIX: the child's ru_maxrss from wait4, which
|
|
45
47
|
# covers the child and the descendants it waited for (exact, not sampled;
|
|
46
48
|
# not summed over the tree; background processes it left running are not
|
|
47
|
-
# included). Windows: the
|
|
48
|
-
#
|
|
49
|
+
# included). Windows: the child's peak working set, read from its handle
|
|
50
|
+
# after it exits (not its descendants'; the largest sampled value if that
|
|
51
|
+
# read fails). None when unavailable. Like RSS, it counts pages of
|
|
52
|
+
# memory-mapped files.
|
|
53
|
+
#
|
|
54
|
+
# peak_private_bytes: the largest sampled total over the process tree of
|
|
55
|
+
# memory that is not a file's pages (see _private_bytes). None on macOS.
|
|
56
|
+
#
|
|
57
|
+
# memory_measure: what the watchdog compared with the limit, "private" or
|
|
58
|
+
# "rss" (asked for, or private memory is unavailable on this platform).
|
|
49
59
|
|
|
50
60
|
def as_dict(self):
|
|
51
61
|
return asdict(self)
|
|
@@ -58,6 +68,7 @@ def capabilities():
|
|
|
58
68
|
"psutil_available": importlib.util.find_spec("psutil") is not None,
|
|
59
69
|
"monitor": True,
|
|
60
70
|
"watchdog": True,
|
|
71
|
+
"private_memory": private_memory_available(),
|
|
61
72
|
"hard_backends": ["cgroup-v1", "cgroup-v2"] if sys.platform == "linux" else [],
|
|
62
73
|
"hard_limit_accounting": "cgroup-charged memory"
|
|
63
74
|
if sys.platform == "linux"
|
|
@@ -78,6 +89,36 @@ def thread_env(threads, env=None):
|
|
|
78
89
|
return out
|
|
79
90
|
|
|
80
91
|
|
|
92
|
+
def _private_bytes(process, info):
|
|
93
|
+
"""Memory of one process that is not a file's pages, which the kernel can
|
|
94
|
+
drop and read back (memory-mapped scratch files, mapped libraries).
|
|
95
|
+
Linux: anonymous plus shared memory (RssAnon + RssShmem; shared memory
|
|
96
|
+
is not reclaimable without swap), else RSS less psutil's shared pages.
|
|
97
|
+
Windows: private bytes (committed memory, so it can exceed RSS). None
|
|
98
|
+
elsewhere (macOS has no cheap equivalent)."""
|
|
99
|
+
if sys.platform.startswith("linux"):
|
|
100
|
+
try:
|
|
101
|
+
found = {}
|
|
102
|
+
with open(f"/proc/{process.pid}/status", encoding="ascii") as fh:
|
|
103
|
+
for line in fh:
|
|
104
|
+
if line.startswith(("RssAnon:", "RssShmem:")):
|
|
105
|
+
key, value = line.split()[:2]
|
|
106
|
+
found[key] = int(value) * 1024
|
|
107
|
+
if "RssAnon:" in found:
|
|
108
|
+
return found["RssAnon:"] + found.get("RssShmem:", 0)
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
pass
|
|
111
|
+
return max(0, info.rss - getattr(info, "shared", 0))
|
|
112
|
+
if hasattr(info, "private"):
|
|
113
|
+
return info.private
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def private_memory_available():
|
|
118
|
+
"""Whether this platform reports private memory (Linux, Windows)."""
|
|
119
|
+
return sys.platform.startswith("linux") or os.name == "nt"
|
|
120
|
+
|
|
121
|
+
|
|
81
122
|
def _positive(value, name):
|
|
82
123
|
if (
|
|
83
124
|
isinstance(value, bool)
|
|
@@ -101,13 +142,20 @@ def run_limited(
|
|
|
101
142
|
max_output_bytes=1_000_000,
|
|
102
143
|
threads: int | None = None,
|
|
103
144
|
capture_output: bool = True,
|
|
145
|
+
memory_measure: str = "private",
|
|
104
146
|
):
|
|
105
147
|
"""Run a command without a shell and return measurements and termination cause.
|
|
106
148
|
|
|
107
149
|
hard: verified Linux cgroup cap, including descendants; no automatic fallback.
|
|
108
|
-
watchdog: sampled process-tree
|
|
150
|
+
watchdog: sampled process-tree memory threshold, which can overshoot.
|
|
109
151
|
monitor: measurements/timeouts only, without a memory ceiling.
|
|
110
152
|
|
|
153
|
+
memory_measure: what the watchdog compares with the limit. "private"
|
|
154
|
+
(default) leaves out pages of files, which the kernel can reclaim, so a
|
|
155
|
+
program that memory-maps large scratch files is not stopped for them;
|
|
156
|
+
where private memory is unavailable (macOS) RSS is used, and the result's
|
|
157
|
+
memory_measure says so. "rss" counts every resident page.
|
|
158
|
+
|
|
111
159
|
GB is decimal. The supervisor stays outside the child's hard cap. env has
|
|
112
160
|
subprocess replacement semantics; no budget/thread variables are injected
|
|
113
161
|
unless `threads` is given, which sets them all (`thread_env`). Captured
|
|
@@ -125,6 +173,13 @@ def run_limited(
|
|
|
125
173
|
)
|
|
126
174
|
if mode not in ("hard", "watchdog", "monitor"):
|
|
127
175
|
raise ValueError("mode must be hard, watchdog or monitor")
|
|
176
|
+
if memory_measure not in ("private", "rss"):
|
|
177
|
+
raise ValueError("memory_measure must be private or rss")
|
|
178
|
+
measure = (
|
|
179
|
+
"private"
|
|
180
|
+
if memory_measure == "private" and private_memory_available()
|
|
181
|
+
else "rss"
|
|
182
|
+
)
|
|
128
183
|
if memory_gb is not None:
|
|
129
184
|
_positive(memory_gb, "memory_gb")
|
|
130
185
|
if mode != "monitor" and memory_gb is None:
|
|
@@ -158,6 +213,7 @@ def run_limited(
|
|
|
158
213
|
group = MemoryCgroup(cgroup_parent, limit) if mode == "hard" else None
|
|
159
214
|
proc, kernel, tracked = None, None, {}
|
|
160
215
|
reason, peak, descendants = None, 0, False
|
|
216
|
+
private_peak = 0 if private_memory_available() else None
|
|
161
217
|
os_peak, stopped = None, False
|
|
162
218
|
sampling_complete = True
|
|
163
219
|
started = time.monotonic()
|
|
@@ -216,6 +272,12 @@ def run_limited(
|
|
|
216
272
|
if proc.poll() is None:
|
|
217
273
|
proc.kill()
|
|
218
274
|
proc.wait()
|
|
275
|
+
# The exited process's counters stay readable while Popen holds
|
|
276
|
+
# its handle: the exact peak working set, not the last sample.
|
|
277
|
+
handle = getattr(proc, "_handle", None)
|
|
278
|
+
counters = _windows_memory(int(handle)) if handle is not None else None
|
|
279
|
+
if counters is not None:
|
|
280
|
+
os_peak = max(os_peak or 0, counters[1])
|
|
219
281
|
|
|
220
282
|
def tail(handle):
|
|
221
283
|
size = handle.seek(0, os.SEEK_END)
|
|
@@ -239,7 +301,7 @@ def run_limited(
|
|
|
239
301
|
except psutil.NoSuchProcess:
|
|
240
302
|
process = None
|
|
241
303
|
while not exited():
|
|
242
|
-
rss = 0
|
|
304
|
+
rss, private = 0, 0
|
|
243
305
|
try:
|
|
244
306
|
members = (
|
|
245
307
|
[process, *process.children(recursive=True)]
|
|
@@ -250,6 +312,8 @@ def run_limited(
|
|
|
250
312
|
try:
|
|
251
313
|
info = p.memory_info()
|
|
252
314
|
rss += info.rss
|
|
315
|
+
if private_peak is not None:
|
|
316
|
+
private += _private_bytes(p, info) or 0
|
|
253
317
|
if hasattr(info, "peak_wset"): # Windows
|
|
254
318
|
os_peak = max(os_peak or 0, info.peak_wset)
|
|
255
319
|
if p.pid != proc.pid:
|
|
@@ -263,10 +327,13 @@ def run_limited(
|
|
|
263
327
|
except psutil.AccessDenied:
|
|
264
328
|
sampling_complete = False
|
|
265
329
|
peak = max(peak, rss)
|
|
330
|
+
if private_peak is not None:
|
|
331
|
+
private_peak = max(private_peak, private)
|
|
266
332
|
if mode == "watchdog" and not sampling_complete:
|
|
267
333
|
reason = "monitoring_failed"
|
|
268
334
|
break
|
|
269
|
-
if
|
|
335
|
+
watched = private if measure == "private" else rss
|
|
336
|
+
if mode == "watchdog" and watched > limit:
|
|
270
337
|
reason = "rss_limit_exceeded"
|
|
271
338
|
break
|
|
272
339
|
if timeout is not None and time.monotonic() - started >= timeout:
|
|
@@ -316,6 +383,8 @@ def run_limited(
|
|
|
316
383
|
descendants,
|
|
317
384
|
sampling_complete,
|
|
318
385
|
os_peak_rss_bytes=os_peak,
|
|
386
|
+
peak_private_bytes=private_peak,
|
|
387
|
+
memory_measure=measure,
|
|
319
388
|
)
|
|
320
389
|
finally:
|
|
321
390
|
try:
|
|
@@ -343,6 +412,12 @@ def main():
|
|
|
343
412
|
help="let the command write to this terminal; the JSON summary goes to stderr",
|
|
344
413
|
)
|
|
345
414
|
parser.add_argument("--report", help="also write the JSON summary to this file")
|
|
415
|
+
parser.add_argument(
|
|
416
|
+
"--memory-measure",
|
|
417
|
+
choices=["private", "rss"],
|
|
418
|
+
default="private",
|
|
419
|
+
help="what the watchdog limits: private memory (default; not file pages) or RSS",
|
|
420
|
+
)
|
|
346
421
|
parser.add_argument("--capabilities", action="store_true")
|
|
347
422
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
348
423
|
args = parser.parse_args()
|
|
@@ -359,6 +434,7 @@ def main():
|
|
|
359
434
|
timeout=args.timeout,
|
|
360
435
|
threads=args.threads,
|
|
361
436
|
capture_output=not args.no_capture,
|
|
437
|
+
memory_measure=args.memory_measure,
|
|
362
438
|
)
|
|
363
439
|
except (ValueError, OSError, RuntimeError, ImportError) as exc:
|
|
364
440
|
parser.error(str(exc))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stream-limits
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -65,7 +65,11 @@ def test_watchdog_and_timeout_have_distinct_causes():
|
|
|
65
65
|
timeout=10,
|
|
66
66
|
)
|
|
67
67
|
assert result.status == "rss_limit_exceeded" and result.returncode != 0
|
|
68
|
-
|
|
68
|
+
# what the watchdog limited: on Windows private bytes count the bytearray
|
|
69
|
+
# once committed, before its pages are resident
|
|
70
|
+
watched = (result.peak_private_bytes if result.memory_measure == "private"
|
|
71
|
+
else result.peak_rss_bytes)
|
|
72
|
+
assert watched > 64_000_000
|
|
69
73
|
result = run_limited(
|
|
70
74
|
python("import time; time.sleep(5)"), mode="monitor", timeout=0.2
|
|
71
75
|
)
|
|
@@ -247,8 +251,6 @@ def test_os_peak_is_exact_and_covers_short_commands():
|
|
|
247
251
|
assert result.status == "ok"
|
|
248
252
|
assert result.os_peak_rss_bytes is not None
|
|
249
253
|
assert result.os_peak_rss_bytes >= 200_000_000
|
|
250
|
-
if os.name != "posix": # Windows reads its peak while sampling
|
|
251
|
-
return
|
|
252
254
|
# too short for any sample: the sampled peak can be 0, the OS's is not
|
|
253
255
|
quick = run_limited(python("pass"), mode="monitor", timeout=30)
|
|
254
256
|
assert quick.os_peak_rss_bytes is not None and quick.os_peak_rss_bytes > 0
|
|
@@ -328,3 +330,65 @@ def test_cli_without_capture_passes_output_through(tmp_path):
|
|
|
328
330
|
summary = json.loads(result.stderr)
|
|
329
331
|
assert summary["status"] == "ok" and summary["stdout"] == ""
|
|
330
332
|
assert json.loads(report.read_text()) == summary
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# A program that writes a 600 MB file and memory-maps it, touching every page:
|
|
336
|
+
# its RSS grows by the file's size, its private memory does not.
|
|
337
|
+
MAPPED = """
|
|
338
|
+
import mmap, os, sys
|
|
339
|
+
path = sys.argv[1]
|
|
340
|
+
with open(path, "wb") as fh:
|
|
341
|
+
fh.truncate(600_000_000)
|
|
342
|
+
with open(path, "r+b") as fh:
|
|
343
|
+
m = mmap.mmap(fh.fileno(), 0)
|
|
344
|
+
for i in range(0, len(m), 4096):
|
|
345
|
+
m[i] = 1
|
|
346
|
+
m.flush()
|
|
347
|
+
total = sum(m[i] for i in range(0, len(m), 4096))
|
|
348
|
+
import time; time.sleep(0.3)
|
|
349
|
+
print(total)
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
|
|
354
|
+
def test_watchdog_ignores_mapped_file_pages_by_default(tmp_path):
|
|
355
|
+
command = [sys.executable, "-c", MAPPED, str(tmp_path / "scratch.bin")]
|
|
356
|
+
result = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60)
|
|
357
|
+
assert result.status == "ok", result.as_dict()
|
|
358
|
+
assert result.memory_measure == "private"
|
|
359
|
+
assert result.peak_private_bytes < 200_000_000
|
|
360
|
+
assert result.peak_rss_bytes > 400_000_000 # the mapped pages are resident
|
|
361
|
+
rss = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60,
|
|
362
|
+
memory_measure="rss")
|
|
363
|
+
assert rss.status == "rss_limit_exceeded" and rss.memory_measure == "rss"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
|
|
367
|
+
def test_watchdog_stops_private_memory_over_the_limit():
|
|
368
|
+
code = "import time; x=bytearray(500_000_000); x[::4096]=b'1'*len(x[::4096]); time.sleep(5)"
|
|
369
|
+
result = run_limited(python(code), mode="watchdog", memory_gb=0.3, timeout=30)
|
|
370
|
+
assert result.status == "rss_limit_exceeded" and result.memory_measure == "private"
|
|
371
|
+
assert result.peak_private_bytes > 300_000_000
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux /proc")
|
|
375
|
+
def test_shared_memory_counts_as_private():
|
|
376
|
+
# shared memory (/dev/shm, multiprocessing) cannot be reclaimed without swap
|
|
377
|
+
code = ("from multiprocessing import shared_memory as s; import time; "
|
|
378
|
+
"m=s.SharedMemory(create=True,size=300_000_000); m.buf[::4096]=b'1'*len(m.buf[::4096]); "
|
|
379
|
+
"time.sleep(0.5); m.close(); m.unlink()")
|
|
380
|
+
result = run_limited(python(code), mode="monitor", timeout=30)
|
|
381
|
+
assert result.status == "ok" and result.peak_private_bytes > 250_000_000
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_private_measure_is_reported_or_falls_back():
|
|
385
|
+
from stream_limits.launcher import private_memory_available
|
|
386
|
+
|
|
387
|
+
result = run_limited(python("import time; time.sleep(0.3)"), mode="watchdog",
|
|
388
|
+
memory_gb=1, timeout=30)
|
|
389
|
+
if private_memory_available():
|
|
390
|
+
assert result.memory_measure == "private" and result.peak_private_bytes > 0
|
|
391
|
+
else:
|
|
392
|
+
assert result.memory_measure == "rss" and result.peak_private_bytes is None
|
|
393
|
+
with pytest.raises(ValueError):
|
|
394
|
+
run_limited(python("pass"), mode="monitor", memory_measure="uss")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|