stream-limits 0.2.1__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.1 → stream_limits-0.3.0}/CHANGELOG.md +23 -0
  2. {stream_limits-0.2.1 → stream_limits-0.3.0}/CITATION.cff +2 -2
  3. {stream_limits-0.2.1/stream_limits.egg-info → stream_limits-0.3.0}/PKG-INFO +1 -1
  4. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/reference.md +2 -0
  5. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/threads.md +20 -0
  6. {stream_limits-0.2.1 → stream_limits-0.3.0}/pyproject.toml +1 -1
  7. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/__init__.py +47 -1
  8. {stream_limits-0.2.1 → stream_limits-0.3.0/stream_limits.egg-info}/PKG-INFO +1 -1
  9. {stream_limits-0.2.1 → stream_limits-0.3.0}/tests/test_override_and_libraries.py +65 -0
  10. {stream_limits-0.2.1 → stream_limits-0.3.0}/LICENSE.md +0 -0
  11. {stream_limits-0.2.1 → stream_limits-0.3.0}/MANIFEST.in +0 -0
  12. {stream_limits-0.2.1 → stream_limits-0.3.0}/README.md +0 -0
  13. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/_static/.gitkeep +0 -0
  14. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/changelog.md +0 -0
  15. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/conf.py +0 -0
  16. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/index.md +0 -0
  17. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/launcher.md +0 -0
  18. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/memory.md +0 -0
  19. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/other-libraries.md +0 -0
  20. {stream_limits-0.2.1 → stream_limits-0.3.0}/docs/schedulers.md +0 -0
  21. {stream_limits-0.2.1 → stream_limits-0.3.0}/setup.cfg +0 -0
  22. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/__main__.py +0 -0
  23. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/_exec_cgroup.py +0 -0
  24. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/enforcement.py +0 -0
  25. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/launcher.py +0 -0
  26. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/py.typed +0 -0
  27. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/pytest_plugin.py +0 -0
  28. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits/testing.py +0 -0
  29. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits.egg-info/SOURCES.txt +0 -0
  30. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits.egg-info/dependency_links.txt +0 -0
  31. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits.egg-info/requires.txt +0 -0
  32. {stream_limits-0.2.1 → stream_limits-0.3.0}/stream_limits.egg-info/top_level.txt +0 -0
  33. {stream_limits-0.2.1 → stream_limits-0.3.0}/tests/conftest.py +0 -0
  34. {stream_limits-0.2.1 → stream_limits-0.3.0}/tests/test_launcher.py +0 -0
  35. {stream_limits-0.2.1 → stream_limits-0.3.0}/tests/test_limits.py +0 -0
@@ -1,5 +1,28 @@
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
+
3
26
  ## 0.2.1
4
27
 
5
28
  - **The watchdog no longer counts the pages of memory-mapped files.** A program
@@ -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.1
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.1
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
@@ -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.1"
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.1"
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,6 +791,51 @@ 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
840
  def _windows_memory(handle=None) -> tuple[int, int] | None:
795
841
  """This process's ``(working set, peak working set)`` in bytes, from the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stream-limits
3
- Version: 0.2.1
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
@@ -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