stream-limits 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {stream_limits-0.2.0 → stream_limits-0.3.0}/CHANGELOG.md +35 -0
  2. {stream_limits-0.2.0 → stream_limits-0.3.0}/CITATION.cff +2 -2
  3. {stream_limits-0.2.0 → stream_limits-0.3.0}/PKG-INFO +1 -1
  4. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/launcher.md +29 -12
  5. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/reference.md +2 -0
  6. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/threads.md +20 -0
  7. {stream_limits-0.2.0 → stream_limits-0.3.0}/pyproject.toml +1 -1
  8. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/__init__.py +52 -5
  9. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/launcher.py +82 -6
  10. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/PKG-INFO +1 -1
  11. {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_launcher.py +67 -3
  12. {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_override_and_libraries.py +65 -0
  13. {stream_limits-0.2.0 → stream_limits-0.3.0}/LICENSE.md +0 -0
  14. {stream_limits-0.2.0 → stream_limits-0.3.0}/MANIFEST.in +0 -0
  15. {stream_limits-0.2.0 → stream_limits-0.3.0}/README.md +0 -0
  16. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/_static/.gitkeep +0 -0
  17. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/changelog.md +0 -0
  18. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/conf.py +0 -0
  19. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/index.md +0 -0
  20. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/memory.md +0 -0
  21. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/other-libraries.md +0 -0
  22. {stream_limits-0.2.0 → stream_limits-0.3.0}/docs/schedulers.md +0 -0
  23. {stream_limits-0.2.0 → stream_limits-0.3.0}/setup.cfg +0 -0
  24. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/__main__.py +0 -0
  25. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/_exec_cgroup.py +0 -0
  26. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/enforcement.py +0 -0
  27. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/py.typed +0 -0
  28. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/pytest_plugin.py +0 -0
  29. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits/testing.py +0 -0
  30. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/SOURCES.txt +0 -0
  31. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/dependency_links.txt +0 -0
  32. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/requires.txt +0 -0
  33. {stream_limits-0.2.0 → stream_limits-0.3.0}/stream_limits.egg-info/top_level.txt +0 -0
  34. {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/conftest.py +0 -0
  35. {stream_limits-0.2.0 → stream_limits-0.3.0}/tests/test_limits.py +0 -0
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - **A new function, `set_openmp_wait_policy`, controls how idle OpenMP threads
6
+ wait.** Programs that make many short parallel calls, such as numba loops run
7
+ once per batch of data, can run noticeably slower when the idle threads spin
8
+ while they wait for the next call, especially on processors that mix fast and
9
+ efficient cores. Telling the threads to sleep instead (`OMP_WAIT_POLICY=passive`,
10
+ the default here) fixed this in diff-stream: fits ran 2% to 15% faster on a
11
+ cloud server, and on a laptop with mixed cores 8 threads went from slower than
12
+ one thread to faster. Programs with a few long parallel calls, such as
13
+ hdfe-stream and logit-stream, run the same either way. On Windows, where
14
+ numba's OpenMP runtime ignores the setting, the call changes nothing.
15
+ - **A package has to ask for it.** Importing stream-limits, and
16
+ `apply_scheduler_defaults`, still change nothing, because the setting reaches
17
+ every OpenMP library in the process (BLAS builds on OpenMP, scikit-learn,
18
+ LightGBM). A value you have set yourself is kept unless you pass
19
+ `override=True`.
20
+ - **It must run before OpenMP starts.** The setting is read once, when the
21
+ OpenMP runtime loads; numba loads its runtime at its first parallel call, so
22
+ calling the function after importing numba is still in time. The function
23
+ reports when a runtime was already loaded (Linux), in which case the setting
24
+ reaches only programs started afterwards.
25
+
26
+ ## 0.2.1
27
+
28
+ - **The watchdog no longer counts the pages of memory-mapped files.** A program
29
+ that memory-maps large scratch files, as hdfe-stream does, has those pages in
30
+ its resident memory, but the kernel can reclaim them, so a watchdog that
31
+ counted them stopped healthy programs: a 20-million-row hdfe-stream fit with
32
+ 1.2 GB of its own memory reached 2.3 GB of RSS. The watchdog now limits
33
+ private memory (Linux, Windows; macOS still uses RSS), reported as
34
+ `peak_private_bytes`. `--memory-measure rss` restores the old behaviour.
35
+ - On Windows, `os_peak_rss_bytes` is now the program's exact peak working set,
36
+ read when it exits, instead of the last value seen while sampling.
37
+
3
38
  ## 0.2.0
4
39
 
5
40
  - **Run a program under a memory limit and see how much memory it used.** The new
@@ -7,8 +7,8 @@ authors:
7
7
  - family-names: Tucker
8
8
  given-names: Lee
9
9
  email: lee.c.tucker@census.gov
10
- version: 0.2.0
11
- date-released: 2026-11-04
10
+ version: 0.3.0
11
+ date-released: 2026-11-08
12
12
  license: MIT
13
13
  repository-code: "https://github.com/leetucker/stream-limits"
14
14
  url: "https://github.com/leetucker/stream-limits"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stream-limits
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -49,7 +49,18 @@ The command is a list of arguments, run without a shell. GB are decimal
49
49
  | `monitor` | Linux, macOS, Windows | No limit: measures, with an optional timeout |
50
50
 
51
51
  **The watchdog** adds up the memory of the program and every process it has
52
- started, and stops them all when the total passes the limit. Because it samples,
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
- Two peaks are reported:
97
+ Three peaks are reported:
87
98
 
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.
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 `os_peak_rss_bytes` at N rows and at several times N.
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.
@@ -15,6 +15,8 @@
15
15
 
16
16
  .. autofunction:: thread_scope
17
17
 
18
+ .. autofunction:: set_openmp_wait_policy
19
+
18
20
  .. autofunction:: polars_spills
19
21
  ```
20
22
 
@@ -81,3 +81,23 @@ allocation, read from the environment, cgroups or the scheduler (see
81
81
  [Schedulers and containers](schedulers.md)), and otherwise the machine's. A job that runs several
82
82
  processes should give each its share, for example
83
83
  `STREAM_LIMITS_MAX_THREADS=$(( NCPUS / 4 ))` for four.
84
+
85
+
86
+ ## How OpenMP threads wait
87
+
88
+ OpenMP threads that finish a parallel region either spin, waiting for the next one, or
89
+ sleep. Spinning is quicker to resume, but a program that makes many short parallel calls
90
+ (numba's `prange` loops, one per batch of data) can run several times slower with it,
91
+ notably on CPUs that mix performance and efficiency cores, and a little slower on others.
92
+ Programs with few, long parallel regions are unaffected: hdfe-stream's solvers and
93
+ logit-stream's fits ran as fast either way.
94
+
95
+ {py:func}`stream_limits.set_openmp_wait_policy` sets `OMP_WAIT_POLICY` (to `"passive"` unless you
96
+ say `"active"`). A package opts in by calling it, because the setting reaches every
97
+ OpenMP library in the process (BLAS builds on OpenMP, scikit-learn, LightGBM); importing
98
+ stream-limits, and `apply_scheduler_defaults`, leave it alone. A value you have set is kept unless
99
+ you pass `override=True`. The runtime reads the variable when it loads, which for numba is
100
+ at its first parallel call, so call the function before that. The result says when a runtime
101
+ was already loaded (Linux), in which case the variable is set for child processes only.
102
+ On Windows, numba uses Microsoft's OpenMP runtime, which does not read the variable, so the
103
+ call changes nothing there.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stream-limits"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -87,10 +87,11 @@ import warnings
87
87
  from pathlib import Path
88
88
  from typing import Iterator, Mapping
89
89
 
90
- __version__ = "0.2.0"
90
+ __version__ = "0.3.0"
91
91
 
92
92
  __all__ = [
93
93
  "set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
94
+ "set_openmp_wait_policy",
94
95
  "detect_cpus", "detect_memory_gb", "describe", "MemoryBudget",
95
96
  "peak_rss_gb", "reset_peak_rss", "current_rss_gb",
96
97
  ]
@@ -790,13 +791,59 @@ def thread_scope(n: int | None = None) -> Iterator[None]:
790
791
  numba.set_num_threads(previous)
791
792
 
792
793
 
794
+ def _openmp_loaded() -> bool | None:
795
+ """Whether an OpenMP runtime is already in this process (Linux only; None
796
+ elsewhere). It reads ``OMP_WAIT_POLICY`` once, when it loads."""
797
+ try:
798
+ maps = Path("/proc/self/maps").read_text()
799
+ except OSError:
800
+ return None
801
+ return any(name in maps for name in ("libgomp", "libiomp5", "libomp"))
802
+
803
+
804
+ def set_openmp_wait_policy(policy: str = "passive", *, override: bool = False,
805
+ environ=None) -> dict:
806
+ """Set how OpenMP threads wait between parallel regions, by ``OMP_WAIT_POLICY``.
807
+
808
+ With ``"passive"`` an idle thread sleeps until the next region; with
809
+ ``"active"`` it spins. A package whose compiled code makes many short
810
+ parallel calls (numba's ``prange`` loops, one per batch of data) can run
811
+ several times slower with spinning threads, notably on CPUs with
812
+ performance and efficiency cores, and a little slower on others. Code with
813
+ few, long parallel regions (hdfe-stream's solvers, logit-stream's fits)
814
+ is unaffected, and this function is not called by importing the package:
815
+ a package opts in, since the setting reaches every OpenMP library in the
816
+ process (BLAS builds on OpenMP, scikit-learn, LightGBM). Microsoft's OpenMP
817
+ runtime, the one numba uses on Windows, does not read the variable, so there
818
+ the call changes nothing.
819
+
820
+ A value the user has set is left alone unless `override`. The runtime reads
821
+ the variable once, when it loads (numba loads its OpenMP layer at the first
822
+ parallel call), so call this before the first parallel call; the result's
823
+ ``"late"`` is True when a runtime was already loaded (Linux), in which case
824
+ the variable is still set, for child processes, but this process is
825
+ unaffected. Returns ``{"policy": ..., "set": bool, "late": bool | None}``,
826
+ where ``"policy"`` is the value in effect."""
827
+ if policy not in ("passive", "active"):
828
+ raise ValueError(f"policy must be 'passive' or 'active', not {policy!r}")
829
+ environ = os.environ if environ is None else environ
830
+ current = environ.get("OMP_WAIT_POLICY")
831
+ if current and not override:
832
+ return {"policy": current.lower(), "set": False, "late": None}
833
+ late = _openmp_loaded() if environ is os.environ else None
834
+ environ["OMP_WAIT_POLICY"] = policy
835
+ return {"policy": policy, "set": True, "late": late}
836
+
837
+
838
+
793
839
  # ----------------------------------------------------- peak memory used
794
- def _windows_memory() -> tuple[int, int] | None:
840
+ def _windows_memory(handle=None) -> tuple[int, int] | None:
795
841
  """This process's ``(working set, peak working set)`` in bytes, from the
796
842
  Windows API (``GetProcessMemoryInfo``), or None: off Windows, or if the call
797
843
  fails. The working set is the physical memory resident in the process, which
798
844
  is what ``VmRSS`` is on Linux, and the peak is a high-water mark the system
799
- keeps."""
845
+ keeps. `handle`: another process's handle instead (the launcher reads a
846
+ child's peak after it exits, while its handle is still open)."""
800
847
  if sys.platform != "win32":
801
848
  return None
802
849
  try:
@@ -821,8 +868,8 @@ def _windows_memory() -> tuple[int, int] | None:
821
868
  kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
822
869
  counters = _Counters()
823
870
  counters.cb = ctypes.sizeof(_Counters)
824
- if not kernel32.K32GetProcessMemoryInfo(kernel32.GetCurrentProcess(),
825
- ctypes.byref(counters), counters.cb):
871
+ target = kernel32.GetCurrentProcess() if handle is None else handle
872
+ if not kernel32.K32GetProcessMemoryInfo(target, ctypes.byref(counters), counters.cb):
826
873
  return None
827
874
  return int(counters.WorkingSetSize), int(counters.PeakWorkingSetSize)
828
875
  except Exception: # noqa: BLE001
@@ -18,7 +18,7 @@ import time
18
18
  from collections.abc import Mapping, Sequence
19
19
  from dataclasses import asdict, dataclass
20
20
 
21
- from . import _THREAD_ENV
21
+ from . import _THREAD_ENV, _windows_memory
22
22
  from .enforcement import MemoryCgroup, UnsupportedEnforcement
23
23
 
24
24
 
@@ -39,13 +39,23 @@ class RunResult:
39
39
  descendants_terminated: bool
40
40
  rss_sampling_complete: bool
41
41
  os_peak_rss_bytes: int | None = None
42
+ peak_private_bytes: int | None = None
43
+ memory_measure: str = "rss"
42
44
 
43
45
  # os_peak_rss_bytes: the largest resident set any single process reached,
44
46
  # as the OS records it. POSIX: the child's ru_maxrss from wait4, which
45
47
  # covers the child and the descendants it waited for (exact, not sampled;
46
48
  # not summed over the tree; background processes it left running are not
47
- # included). Windows: the largest peak working set read while sampling.
48
- # None when unavailable.
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 RSS threshold, which can overshoot.
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 mode == "watchdog" and rss > limit:
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.0
3
+ Version: 0.3.0
4
4
  Summary: Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -65,7 +65,11 @@ def test_watchdog_and_timeout_have_distinct_causes():
65
65
  timeout=10,
66
66
  )
67
67
  assert result.status == "rss_limit_exceeded" and result.returncode != 0
68
- assert result.peak_rss_bytes > 64_000_000
68
+ # what the watchdog limited: on Windows private bytes count the bytearray
69
+ # once committed, before its pages are resident
70
+ watched = (result.peak_private_bytes if result.memory_measure == "private"
71
+ else result.peak_rss_bytes)
72
+ assert watched > 64_000_000
69
73
  result = run_limited(
70
74
  python("import time; time.sleep(5)"), mode="monitor", timeout=0.2
71
75
  )
@@ -247,8 +251,6 @@ def test_os_peak_is_exact_and_covers_short_commands():
247
251
  assert result.status == "ok"
248
252
  assert result.os_peak_rss_bytes is not None
249
253
  assert result.os_peak_rss_bytes >= 200_000_000
250
- if os.name != "posix": # Windows reads its peak while sampling
251
- return
252
254
  # too short for any sample: the sampled peak can be 0, the OS's is not
253
255
  quick = run_limited(python("pass"), mode="monitor", timeout=30)
254
256
  assert quick.os_peak_rss_bytes is not None and quick.os_peak_rss_bytes > 0
@@ -328,3 +330,65 @@ def test_cli_without_capture_passes_output_through(tmp_path):
328
330
  summary = json.loads(result.stderr)
329
331
  assert summary["status"] == "ok" and summary["stdout"] == ""
330
332
  assert json.loads(report.read_text()) == summary
333
+
334
+
335
+ # A program that writes a 600 MB file and memory-maps it, touching every page:
336
+ # its RSS grows by the file's size, its private memory does not.
337
+ MAPPED = """
338
+ import mmap, os, sys
339
+ path = sys.argv[1]
340
+ with open(path, "wb") as fh:
341
+ fh.truncate(600_000_000)
342
+ with open(path, "r+b") as fh:
343
+ m = mmap.mmap(fh.fileno(), 0)
344
+ for i in range(0, len(m), 4096):
345
+ m[i] = 1
346
+ m.flush()
347
+ total = sum(m[i] for i in range(0, len(m), 4096))
348
+ import time; time.sleep(0.3)
349
+ print(total)
350
+ """
351
+
352
+
353
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
354
+ def test_watchdog_ignores_mapped_file_pages_by_default(tmp_path):
355
+ command = [sys.executable, "-c", MAPPED, str(tmp_path / "scratch.bin")]
356
+ result = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60)
357
+ assert result.status == "ok", result.as_dict()
358
+ assert result.memory_measure == "private"
359
+ assert result.peak_private_bytes < 200_000_000
360
+ assert result.peak_rss_bytes > 400_000_000 # the mapped pages are resident
361
+ rss = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60,
362
+ memory_measure="rss")
363
+ assert rss.status == "rss_limit_exceeded" and rss.memory_measure == "rss"
364
+
365
+
366
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
367
+ def test_watchdog_stops_private_memory_over_the_limit():
368
+ code = "import time; x=bytearray(500_000_000); x[::4096]=b'1'*len(x[::4096]); time.sleep(5)"
369
+ result = run_limited(python(code), mode="watchdog", memory_gb=0.3, timeout=30)
370
+ assert result.status == "rss_limit_exceeded" and result.memory_measure == "private"
371
+ assert result.peak_private_bytes > 300_000_000
372
+
373
+
374
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux /proc")
375
+ def test_shared_memory_counts_as_private():
376
+ # shared memory (/dev/shm, multiprocessing) cannot be reclaimed without swap
377
+ code = ("from multiprocessing import shared_memory as s; import time; "
378
+ "m=s.SharedMemory(create=True,size=300_000_000); m.buf[::4096]=b'1'*len(m.buf[::4096]); "
379
+ "time.sleep(0.5); m.close(); m.unlink()")
380
+ result = run_limited(python(code), mode="monitor", timeout=30)
381
+ assert result.status == "ok" and result.peak_private_bytes > 250_000_000
382
+
383
+
384
+ def test_private_measure_is_reported_or_falls_back():
385
+ from stream_limits.launcher import private_memory_available
386
+
387
+ result = run_limited(python("import time; time.sleep(0.3)"), mode="watchdog",
388
+ memory_gb=1, timeout=30)
389
+ if private_memory_available():
390
+ assert result.memory_measure == "private" and result.peak_private_bytes > 0
391
+ else:
392
+ assert result.memory_measure == "rss" and result.peak_private_bytes is None
393
+ with pytest.raises(ValueError):
394
+ run_limited(python("pass"), mode="monitor", memory_measure="uss")
@@ -213,3 +213,68 @@ def test_a_preset_spill_dir_is_kept_unless_override(tmp_path):
213
213
  a = _run(code.format(extra=""), POLARS_OOC_SPILL_DIR="/somewhere/else")
214
214
  b = _run(code.format(extra=", override=True"), POLARS_OOC_SPILL_DIR="/somewhere/else")
215
215
  assert a.stdout.strip() == "/somewhere/else" and b.stdout.strip() == str(tmp_path), (a.stderr, b.stderr)
216
+
217
+
218
+ # --- set_openmp_wait_policy ---
219
+
220
+ def test_wait_policy_sets_the_variable_and_respects_the_user():
221
+ env = {}
222
+ assert sl.set_openmp_wait_policy(environ=env) == {"policy": "passive", "set": True, "late": None}
223
+ assert env["OMP_WAIT_POLICY"] == "passive"
224
+ env = {"OMP_WAIT_POLICY": "ACTIVE"}
225
+ assert sl.set_openmp_wait_policy(environ=env) == {"policy": "active", "set": False, "late": None}
226
+ assert env["OMP_WAIT_POLICY"] == "ACTIVE"
227
+ assert sl.set_openmp_wait_policy(environ=env, override=True)["set"] is True
228
+ assert env["OMP_WAIT_POLICY"] == "passive"
229
+ with pytest.raises(ValueError):
230
+ sl.set_openmp_wait_policy("sleepy", environ={})
231
+
232
+
233
+ def test_importing_the_module_leaves_the_policy_alone():
234
+ r = _run("import os, stream_limits; print(os.environ.get('OMP_WAIT_POLICY'))")
235
+ assert r.stdout.strip() == "None"
236
+ r = _run("import os, stream_limits; stream_limits.apply_scheduler_defaults(); "
237
+ "print(os.environ.get('OMP_WAIT_POLICY'))", NCPUS="2")
238
+ assert r.stdout.strip() == "None"
239
+
240
+
241
+ NUMBA_PROBE = """
242
+ import numpy as np, sys
243
+ {before}
244
+ import numba
245
+ from numba import njit, prange
246
+ import stream_limits
247
+ print(stream_limits.set_openmp_wait_policy()["late"])
248
+ @njit(parallel=True)
249
+ def f(a):
250
+ s = 0.0
251
+ for i in prange(a.size):
252
+ s += a[i]
253
+ return s
254
+ f(np.ones(100))
255
+ {after}
256
+ """
257
+
258
+
259
+ def test_wait_policy_takes_effect_when_numba_is_imported_first():
260
+ # numba loads its OpenMP runtime at the first parallel call, which is after
261
+ r = _run(NUMBA_PROBE.format(before="", after="print(numba.threading_layer())"),
262
+ OMP_DISPLAY_ENV="verbose")
263
+ if "omp" not in r.stdout.split():
264
+ pytest.skip("numba's OpenMP threading layer is not in use")
265
+ # "late" is only detectable on Linux (it reads /proc/self/maps)
266
+ assert r.stdout.split()[0] == ("False" if sys.platform.startswith("linux") else "None")
267
+ if sys.platform == "win32":
268
+ pytest.skip("Microsoft's OpenMP runtime does not read or display OMP_WAIT_POLICY")
269
+ assert "OMP_WAIT_POLICY = 'PASSIVE'" in r.stderr
270
+
271
+
272
+ def test_wait_policy_reports_a_runtime_that_is_already_loaded():
273
+ r = _run(NUMBA_PROBE.format(before="", after="") +
274
+ "\nimport stream_limits as s\nprint(s.set_openmp_wait_policy(override=True)['late'])")
275
+ if not sys.platform.startswith("linux"):
276
+ pytest.skip("detection needs /proc/self/maps")
277
+ lines = r.stdout.split()
278
+ if lines[-1] == "False":
279
+ pytest.skip("numba's OpenMP layer is not in use")
280
+ assert lines[-1] == "True"
File without changes
File without changes
File without changes
File without changes