stream-limits 0.1.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.
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release, coinciding with updates to hdfe-stream, logit-stream and
6
+ dpd-stream to use it. Anyone running other numeric code on a scheduler can use
7
+ it on its own.
8
+
9
+ **Limits**
10
+
11
+ - `set_limits(threads=None, memory_gb=None)` applies thread and memory limits in one call. Values you
12
+ give are used; values you omit are the job's allocation if one is found, and nothing otherwise.
13
+ - `limit_threads` caps Polars, numba, NumPy/SciPy's BLAS (OpenBLAS, MKL, BLIS, Accelerate), OpenMP
14
+ programs (LightGBM, XGBoost, scikit-learn), pyarrow, numexpr, PyTorch, TensorFlow, rayon-based Rust
15
+ libraries, joblib and Julia. It warns when Polars was imported first and can no longer be capped.
16
+ - `limit_memory` gives Polars 2.x an out-of-core memory budget, so its streaming engine spills to disk
17
+ instead of growing (a streaming sort of 1.9 GB peaked at 6.2 GB; with a budget, 1.8 GB).
18
+ - `apply_scheduler_defaults` does both, only when a limit is found. The packages built on this one call
19
+ it when imported.
20
+ - Variables you have set are left alone, and loaded libraries are only lowered; a variable set above the
21
+ cap is named in a warning. `override=True` replaces them.
22
+ - `thread_scope` holds numba and BLAS to a thread count for one block of code.
23
+ - Every setting can be given as a `STREAM_LIMITS_*` environment variable (`STREAM_LIMITS_MAX_THREADS`,
24
+ `STREAM_LIMITS_MEMORY_GB`, ...), which is how a job script sets it. Values passed to `set_limits` are
25
+ recorded there too, so the rest of the process, child processes and the packages built on this one agree.
26
+
27
+ **Finding the allocation**
28
+
29
+ - `detect_cpus` and `detect_memory_gb` read the environment, cgroup v1/v2 limits (the lowest on the path
30
+ to the root), PBS (`NCPUS`, `PBS_NUM_PPN`, ...; `qstat -f $PBS_JOBID` when no variable or cgroup says)
31
+ and Slurm.
32
+ - `STREAM_LIMITS_CPU_VARS` and `STREAM_LIMITS_MEMORY_VARS` name a site's own variables.
33
+ - `describe` and `python -m stream_limits` show what was found and where it came from.
34
+
35
+ **Memory**
36
+
37
+ - `MemoryBudget` gives the headroom left, for code that chooses between a fast path that needs memory and
38
+ a slower one that needs less.
39
+ - `peak_rss_gb`, `reset_peak_rss` and `current_rss_gb` read the kernel's own counters.
40
+
41
+ **Documentation**
42
+
43
+ - A Sphinx site, built in CI: thread limits, memory limits, schedulers and containers, and recipes for
44
+ libraries with only per-object settings (DuckDB, Dask, Ray, JAX, Spark).
@@ -0,0 +1,21 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software in your research, please cite it as below."
3
+ type: software
4
+ title: "stream-limits"
5
+ abstract: "Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow, ...) at a scheduler's allocation, and measure peak memory."
6
+ authors:
7
+ - family-names: Tucker
8
+ given-names: Lee
9
+ email: lee.c.tucker@census.gov
10
+ version: 0.1.0
11
+ date-released: 2026-10-31
12
+ license: MIT
13
+ repository-code: "https://github.com/leetucker/stream-limits"
14
+ url: "https://github.com/leetucker/stream-limits"
15
+ keywords:
16
+ - threads
17
+ - memory
18
+ - PBS
19
+ - Slurm
20
+ - cgroups
21
+ - HPC
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Lee Tucker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ include README.md CHANGELOG.md CITATION.cff LICENSE.md
2
+ graft tests
3
+ graft docs
4
+ prune docs/_build
5
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: stream-limits
3
+ Version: 0.1.0
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
+ Author: Lee Tucker
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/leetucker/stream-limits
8
+ Project-URL: Documentation, https://leetucker.github.io/stream-limits/
9
+ Project-URL: Changelog, https://github.com/leetucker/stream-limits/blob/main/CHANGELOG.md
10
+ Project-URL: Source, https://github.com/leetucker/stream-limits
11
+ Keywords: threads,memory,PBS,Slurm,cgroup,polars,numba,HPC
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
24
+ Classifier: Topic :: System :: Hardware
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE.md
29
+ Provides-Extra: blas
30
+ Requires-Dist: threadpoolctl>=3.1; extra == "blas"
31
+ Provides-Extra: docs
32
+ Requires-Dist: sphinx>=8.0; extra == "docs"
33
+ Requires-Dist: myst-parser>=4.0; extra == "docs"
34
+ Requires-Dist: furo>=2024.8.6; extra == "docs"
35
+ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest>=7.0; extra == "test"
38
+ Requires-Dist: numpy; extra == "test"
39
+ Requires-Dist: numba; extra == "test"
40
+ Requires-Dist: polars; extra == "test"
41
+ Requires-Dist: pyarrow; extra == "test"
42
+ Requires-Dist: threadpoolctl>=3.1; extra == "test"
43
+ Requires-Dist: numexpr; python_version < "3.15" and extra == "test"
44
+ Dynamic: license-file
45
+
46
+ # stream-limits
47
+
48
+ Cap the threads and memory of numeric Python libraries at a job's allocation, with one
49
+ interface, and measure the memory a block of code used.
50
+
51
+ On a cluster (PBS, Slurm, a container) a job gets a fixed number of CPUs and a fixed
52
+ amount of memory, and is killed if it exceeds either. Polars, numba, BLAS and pyarrow
53
+ each size their thread pools separately and default to every core of the *node*; code
54
+ that asks how much memory there is sees the node's too. `stream-limits` finds the job's
55
+ allocation, applies it to each library as far as the library allows, and says so when it
56
+ cannot. It depends on nothing outside the standard library and imports none of the
57
+ libraries it limits, so it can run before they are imported, which is when some of them
58
+ (Polars) need to be told.
59
+
60
+ It is what [hdfe-stream](https://github.com/leetucker/hdfe-stream),
61
+ [logit-stream](https://github.com/leetucker/logit-stream) and
62
+ [dpd-stream](https://github.com/leetucker/dpd-stream) use, and it works on its own.
63
+ Documentation: <https://leetucker.github.io/stream-limits/> (build locally with
64
+ `pip install -e ".[docs]"` and `sphinx-build -W -b html docs docs/_build/html`).
65
+
66
+ ```python
67
+ import stream_limits
68
+ stream_limits.set_limits() # the job's allocation; or set_limits(threads=8, memory_gb=16)
69
+
70
+ import polars as pl # now capped
71
+ import numpy as np
72
+ ```
73
+
74
+ ```
75
+ $ python -m stream_limits # what was found, from inside a job
76
+ {"cpus": 8, "cpus_source": "PBS_NCPUS", "memory_gb": 32.0, "memory_source": "cgroup memory limit", ...}
77
+ ```
78
+
79
+ Variables you have set are **not overridden** by default (a warning names any you set above the cap);
80
+ loaded libraries are only lowered. `override=True` replaces them. More libraries are covered than the
81
+ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia), and the documentation's
82
+ "Other libraries" has recipes for those with only per-object settings (DuckDB, Dask, Ray, JAX, Spark).
83
+
84
+ ## What each library allows
85
+
86
+ | Library | Can it be capped after import? | What `limit_threads` does |
87
+ |---|---|---|
88
+ | Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
89
+ | numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
90
+ | BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
91
+ | pyarrow | yes | `set_cpu_count`, `set_io_thread_count` |
92
+
93
+ `thread_scope(n)` holds numba and BLAS to `n` threads for a block and restores them.
94
+
95
+ ## Memory
96
+
97
+ - `detect_memory_gb()` is the memory the process may use: `STREAM_LIMITS_MEMORY_GB`, else the
98
+ cgroup limit (the lowest on the path to the root, so a scheduler's job limit is found), else
99
+ PBS's own record of the job, else `SLURM_MEM_PER_NODE`, else the machine's. PBS sites often set
100
+ no memory variable and enforce no cgroup; there `qstat -f $PBS_JOBID` is run once (no shell, a
101
+ 10 s timeout, cached) and `Resource_List.mem` (else `vmem`) is read, with PBS's size suffixes.
102
+ `STREAM_LIMITS_QSTAT=0` turns this off.
103
+ - `limit_memory()` gives Polars 2.x an out-of-core budget (`POLARS_OOC_MEMORY_BUDGET_MB`),
104
+ past which its streaming engine spills to disk instead of growing. A streaming sort of 1.9 GB
105
+ of Parquet peaked at 6.2 GB with the defaults and 1.8 GB with a 400 MB budget, in twice the
106
+ time. The budget bounds what Polars' operators hold, not the whole process (its Parquet
107
+ readers hold about 1.5 GB more on 8 threads). This is undocumented in Polars 2.x and read at
108
+ import, so it is set only for Polars 2.0 or later, only before Polars is imported, never over
109
+ a budget you set, and `STREAM_LIMITS_POLARS_SPILL=0` turns it off.
110
+ - `MemoryBudget(limit_gb, reserve)` is for code that chooses between a fast path that needs
111
+ memory and a slower one that needs less: `budget.headroom_gb()` is the limit, less a reserve
112
+ (default 15%, `STREAM_LIMITS_MEMORY_RESERVE`) for what the code does not model, less what the
113
+ process holds now. Ask whether your exact allocation fits, not whether it is under a fixed
114
+ fraction of the machine.
115
+ - `peak_rss_gb()` / `reset_peak_rss()` read the kernel's own high-water mark (Linux): free, no
116
+ polling.
117
+
118
+ `set_limits` is `limit_threads` and `limit_memory` in one call: values you give are applied, values you
119
+ leave out are the job's allocation if one is found and nothing otherwise. The two remain, for their
120
+ own options (`spill_dir`, `fraction`, ...).
121
+
122
+ ## Automatically
123
+
124
+ `apply_scheduler_defaults()` (which is `set_limits()` with no arguments) does `limit_threads` and `limit_memory`, only for limits it finds
125
+ (a variable, a scheduler's environment, a cgroup, a restricted CPU affinity), and sets only
126
+ variables you have not set. Libraries built on this call it when they are imported; importing
127
+ `stream_limits` itself changes nothing.
128
+
129
+ ## Environment variables
130
+
131
+ | Variable | Meaning |
132
+ |---|---|
133
+ | `STREAM_LIMITS_MAX_THREADS` | the thread cap |
134
+ | `STREAM_LIMITS_MEMORY_GB` | the memory limit |
135
+ | `STREAM_LIMITS_MEMORY_RESERVE` | `MemoryBudget`'s reserve (default 0.15) |
136
+ | `STREAM_LIMITS_POLARS_SPILL` | `0`: do not set Polars' budget |
137
+ | `STREAM_LIMITS_POLARS_FRACTION` | the budget's share of the limit above the readers' floor (default 0.5) |
138
+ | `STREAM_LIMITS_QSTAT` | `0`: do not run `qstat` to find a PBS job's CPUs and memory |
139
+ | `STREAM_LIMITS_CPU_VARS` | names of variables holding a CPU count, checked first (comma-separated) |
140
+ | `STREAM_LIMITS_MEMORY_VARS` | the same for memory; each holds a size (`16gb`) |
141
+ | `STREAM_LIMITS_SPILL_DIR` | where Polars spills (default its temp dir: on a cluster, check `$TMPDIR`) |
142
+
143
+ Thread limits are read from, in order: the argument; `STREAM_LIMITS_MAX_THREADS`; the variables named
144
+ in `STREAM_LIMITS_CPU_VARS`; `NCPUS`, `PBS_NCPUS`, `PBS_NUM_PPN`, `PBS_NP`; `SLURM_CPUS_PER_TASK`,
145
+ `SLURM_CPUS_ON_NODE`; the cgroup CPU quota; `qstat -f $PBS_JOBID`; the CPU affinity; `os.cpu_count()`.
146
+ See the documentation's "Schedulers and containers" for each source and for sites that do it differently.
147
+
148
+ ## Install
149
+
150
+ ```
151
+ pip install stream-limits # nothing else is installed
152
+ pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
153
+ ```
154
+
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
157
+ Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
158
+
159
+ MIT licence.
@@ -0,0 +1,114 @@
1
+ # stream-limits
2
+
3
+ Cap the threads and memory of numeric Python libraries at a job's allocation, with one
4
+ interface, and measure the memory a block of code used.
5
+
6
+ On a cluster (PBS, Slurm, a container) a job gets a fixed number of CPUs and a fixed
7
+ amount of memory, and is killed if it exceeds either. Polars, numba, BLAS and pyarrow
8
+ each size their thread pools separately and default to every core of the *node*; code
9
+ that asks how much memory there is sees the node's too. `stream-limits` finds the job's
10
+ allocation, applies it to each library as far as the library allows, and says so when it
11
+ cannot. It depends on nothing outside the standard library and imports none of the
12
+ libraries it limits, so it can run before they are imported, which is when some of them
13
+ (Polars) need to be told.
14
+
15
+ It is what [hdfe-stream](https://github.com/leetucker/hdfe-stream),
16
+ [logit-stream](https://github.com/leetucker/logit-stream) and
17
+ [dpd-stream](https://github.com/leetucker/dpd-stream) use, and it works on its own.
18
+ Documentation: <https://leetucker.github.io/stream-limits/> (build locally with
19
+ `pip install -e ".[docs]"` and `sphinx-build -W -b html docs docs/_build/html`).
20
+
21
+ ```python
22
+ import stream_limits
23
+ stream_limits.set_limits() # the job's allocation; or set_limits(threads=8, memory_gb=16)
24
+
25
+ import polars as pl # now capped
26
+ import numpy as np
27
+ ```
28
+
29
+ ```
30
+ $ python -m stream_limits # what was found, from inside a job
31
+ {"cpus": 8, "cpus_source": "PBS_NCPUS", "memory_gb": 32.0, "memory_source": "cgroup memory limit", ...}
32
+ ```
33
+
34
+ Variables you have set are **not overridden** by default (a warning names any you set above the cap);
35
+ loaded libraries are only lowered. `override=True` replaces them. More libraries are covered than the
36
+ table below (numexpr, PyTorch, OpenMP programs, TensorFlow, rayon, joblib, Julia), and the documentation's
37
+ "Other libraries" has recipes for those with only per-object settings (DuckDB, Dask, Ray, JAX, Spark).
38
+
39
+ ## What each library allows
40
+
41
+ | Library | Can it be capped after import? | What `limit_threads` does |
42
+ |---|---|---|
43
+ | Polars | **No**: the pool is sized once, at import | sets `POLARS_MAX_THREADS` (before import); warns if too late |
44
+ | numba | down, never above `NUMBA_NUM_THREADS` at import | sets the variable; lowers `set_num_threads` |
45
+ | BLAS (NumPy, SciPy) | yes, through `threadpoolctl` | sets `OMP_`/`OPENBLAS_`/`MKL_NUM_THREADS`; `threadpool_limits` |
46
+ | pyarrow | yes | `set_cpu_count`, `set_io_thread_count` |
47
+
48
+ `thread_scope(n)` holds numba and BLAS to `n` threads for a block and restores them.
49
+
50
+ ## Memory
51
+
52
+ - `detect_memory_gb()` is the memory the process may use: `STREAM_LIMITS_MEMORY_GB`, else the
53
+ cgroup limit (the lowest on the path to the root, so a scheduler's job limit is found), else
54
+ PBS's own record of the job, else `SLURM_MEM_PER_NODE`, else the machine's. PBS sites often set
55
+ no memory variable and enforce no cgroup; there `qstat -f $PBS_JOBID` is run once (no shell, a
56
+ 10 s timeout, cached) and `Resource_List.mem` (else `vmem`) is read, with PBS's size suffixes.
57
+ `STREAM_LIMITS_QSTAT=0` turns this off.
58
+ - `limit_memory()` gives Polars 2.x an out-of-core budget (`POLARS_OOC_MEMORY_BUDGET_MB`),
59
+ past which its streaming engine spills to disk instead of growing. A streaming sort of 1.9 GB
60
+ of Parquet peaked at 6.2 GB with the defaults and 1.8 GB with a 400 MB budget, in twice the
61
+ time. The budget bounds what Polars' operators hold, not the whole process (its Parquet
62
+ readers hold about 1.5 GB more on 8 threads). This is undocumented in Polars 2.x and read at
63
+ import, so it is set only for Polars 2.0 or later, only before Polars is imported, never over
64
+ a budget you set, and `STREAM_LIMITS_POLARS_SPILL=0` turns it off.
65
+ - `MemoryBudget(limit_gb, reserve)` is for code that chooses between a fast path that needs
66
+ memory and a slower one that needs less: `budget.headroom_gb()` is the limit, less a reserve
67
+ (default 15%, `STREAM_LIMITS_MEMORY_RESERVE`) for what the code does not model, less what the
68
+ process holds now. Ask whether your exact allocation fits, not whether it is under a fixed
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.
72
+
73
+ `set_limits` is `limit_threads` and `limit_memory` in one call: values you give are applied, values you
74
+ leave out are the job's allocation if one is found and nothing otherwise. The two remain, for their
75
+ own options (`spill_dir`, `fraction`, ...).
76
+
77
+ ## Automatically
78
+
79
+ `apply_scheduler_defaults()` (which is `set_limits()` with no arguments) does `limit_threads` and `limit_memory`, only for limits it finds
80
+ (a variable, a scheduler's environment, a cgroup, a restricted CPU affinity), and sets only
81
+ variables you have not set. Libraries built on this call it when they are imported; importing
82
+ `stream_limits` itself changes nothing.
83
+
84
+ ## Environment variables
85
+
86
+ | Variable | Meaning |
87
+ |---|---|
88
+ | `STREAM_LIMITS_MAX_THREADS` | the thread cap |
89
+ | `STREAM_LIMITS_MEMORY_GB` | the memory limit |
90
+ | `STREAM_LIMITS_MEMORY_RESERVE` | `MemoryBudget`'s reserve (default 0.15) |
91
+ | `STREAM_LIMITS_POLARS_SPILL` | `0`: do not set Polars' budget |
92
+ | `STREAM_LIMITS_POLARS_FRACTION` | the budget's share of the limit above the readers' floor (default 0.5) |
93
+ | `STREAM_LIMITS_QSTAT` | `0`: do not run `qstat` to find a PBS job's CPUs and memory |
94
+ | `STREAM_LIMITS_CPU_VARS` | names of variables holding a CPU count, checked first (comma-separated) |
95
+ | `STREAM_LIMITS_MEMORY_VARS` | the same for memory; each holds a size (`16gb`) |
96
+ | `STREAM_LIMITS_SPILL_DIR` | where Polars spills (default its temp dir: on a cluster, check `$TMPDIR`) |
97
+
98
+ Thread limits are read from, in order: the argument; `STREAM_LIMITS_MAX_THREADS`; the variables named
99
+ in `STREAM_LIMITS_CPU_VARS`; `NCPUS`, `PBS_NCPUS`, `PBS_NUM_PPN`, `PBS_NP`; `SLURM_CPUS_PER_TASK`,
100
+ `SLURM_CPUS_ON_NODE`; the cgroup CPU quota; `qstat -f $PBS_JOBID`; the CPU affinity; `os.cpu_count()`.
101
+ See the documentation's "Schedulers and containers" for each source and for sites that do it differently.
102
+
103
+ ## Install
104
+
105
+ ```
106
+ pip install stream-limits # nothing else is installed
107
+ pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
108
+ ```
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
112
+ Accelerate BLAS is limited only by `VECLIB_MAXIMUM_THREADS` before NumPy is imported (threadpoolctl cannot see it).
113
+
114
+ MIT licence.
File without changes
@@ -0,0 +1,2 @@
1
+ ```{include} ../CHANGELOG.md
2
+ ```
@@ -0,0 +1,59 @@
1
+ """Sphinx configuration for the stream-limits documentation.
2
+
3
+ Build locally with
4
+
5
+ pip install -e ".[docs]"
6
+ sphinx-build -W --keep-going -b html docs docs/_build/html
7
+ """
8
+
9
+ import stream_limits
10
+
11
+ project = "stream-limits"
12
+ author = "Lee Tucker"
13
+ copyright = "2026, Lee Tucker"
14
+ release = stream_limits.__version__
15
+ version = release
16
+
17
+ extensions = [
18
+ "myst_parser",
19
+ "sphinx.ext.autodoc",
20
+ "sphinx.ext.napoleon",
21
+ "sphinx.ext.intersphinx",
22
+ "sphinx_copybutton",
23
+ ]
24
+
25
+ source_suffix = {".md": "markdown", ".rst": "restructuredtext"}
26
+ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
27
+ # fenced blocks without a language are output and tables, not code
28
+ highlight_language = "text"
29
+
30
+ myst_enable_extensions = ["colon_fence", "deflist", "attrs_inline"]
31
+ myst_heading_anchors = 4
32
+
33
+ autodoc_typehints = "none"
34
+ autodoc_member_order = "bysource"
35
+ napoleon_google_docstring = False
36
+ napoleon_numpy_docstring = True
37
+ napoleon_use_rtype = False
38
+ default_role = "py:obj"
39
+
40
+ intersphinx_mapping = {
41
+ "python": ("https://docs.python.org/3", None),
42
+ "polars": ("https://docs.pola.rs/api/python/stable", None),
43
+ "hdfe_stream": ("https://leetucker.github.io/hdfe-stream", None),
44
+ }
45
+ # the inventories are fetched at build time; a build without network access
46
+ # still succeeds, with those links left as plain text
47
+ suppress_warnings = ["intersphinx.external", "config.cache"]
48
+
49
+ html_theme = "furo"
50
+ html_title = f"stream-limits {release}"
51
+ html_static_path = ["_static"]
52
+ html_theme_options = {
53
+ "source_repository": "https://github.com/leetucker/stream-limits/",
54
+ "source_branch": "main",
55
+ "source_directory": "docs/",
56
+ }
57
+
58
+ copybutton_prompt_text = r">>> |\.\.\. |\$ "
59
+ copybutton_prompt_is_regexp = True
@@ -0,0 +1,84 @@
1
+ # stream-limits
2
+
3
+ Cap the threads and memory of numeric Python libraries at a job's allocation, with one
4
+ interface, and measure the memory a block of code used.
5
+
6
+ On a cluster (PBS, Slurm, a container) a job gets a fixed number of CPUs and a fixed
7
+ amount of memory, and is killed if it exceeds either. Polars, numba, BLAS and pyarrow
8
+ each size their thread pools separately and default to every core of the *node*; code
9
+ that asks how much memory there is sees the node's too. `stream-limits` finds the job's
10
+ allocation, applies it to each library as far as the library allows, and says so when it
11
+ cannot. It depends on nothing outside the standard library and imports none of the
12
+ libraries it limits, so it can run before they are imported, which is when some of them
13
+ (Polars) need to be told.
14
+
15
+ It is what [hdfe-stream](https://leetucker.github.io/hdfe-stream/),
16
+ [logit-stream](https://leetucker.github.io/logit-stream/) and
17
+ [dpd-stream](https://leetucker.github.io/dpd-stream/) use, and it works on its own.
18
+
19
+ ```python
20
+ import stream_limits
21
+ stream_limits.set_limits() # the job's allocation; or set_limits(threads=8, memory_gb=16)
22
+
23
+ import polars as pl # now capped
24
+ import numpy as np
25
+ ```
26
+
27
+ `set_limits` is {py:func}`~stream_limits.limit_threads` and {py:func}`~stream_limits.limit_memory`
28
+ in one call. Values you give are applied; values you leave out are the job's allocation if one is
29
+ found, and nothing otherwise, so the same line is right on a cluster and on a laptop. The two
30
+ functions it calls remain, for the options the one call leaves out.
31
+
32
+ ```
33
+ $ python -m stream_limits # what was found, from inside a job
34
+ {
35
+ "cpus": 8,
36
+ "cpus_source": "NCPUS",
37
+ "memory_gb": 17.18,
38
+ "memory_source": "qstat Resource_List.mem",
39
+ ...
40
+ }
41
+ ```
42
+
43
+ ## Where to start
44
+
45
+ - **On a cluster, before anything else**: run `python -m stream_limits` inside a job and check
46
+ that the CPUs and memory it reports are the ones you asked for. If they are not, see
47
+ [Schedulers and containers](schedulers.md), which says where each number comes from and how
48
+ to tell the library about a site that does it differently.
49
+ - **To cap threads**: [Thread limits](threads.md).
50
+ - **For a library that is not covered** (DuckDB, Dask, Ray, JAX, Spark, ...): [Other libraries](other-libraries.md).
51
+ - **To bound memory**: [Memory limits](memory.md), which covers the memory a job has,
52
+ Polars' spill-to-disk budget, and `MemoryBudget`, for code that chooses between a fast path
53
+ that needs memory and a slower one that needs less.
54
+ - **If you use hdfe-stream, logit-stream or dpd-stream**: they call
55
+ `apply_scheduler_defaults()` when imported, so a job is capped without a line of your code,
56
+ and their `max_memory_gb` and `n_threads` options use what is described here. Their
57
+ `profile=True` option, which estimates a fit's memory before you run it, is documented in
58
+ [hdfe-stream](https://leetucker.github.io/hdfe-stream/resources.html).
59
+
60
+ ## Automatically, or explicitly
61
+
62
+ `apply_scheduler_defaults()` (which is `set_limits()` with no arguments) does `limit_threads` and
63
+ `limit_memory`, **only for limits it finds** (a variable, a scheduler's environment, a cgroup, a restricted CPU affinity), and sets
64
+ only variables you have not set yourself; with no limit it does nothing, so a workstation is
65
+ untouched. Libraries built on `stream-limits` call it when they are imported. Importing
66
+ `stream_limits` itself changes nothing.
67
+
68
+ ## Install
69
+
70
+ ```
71
+ pip install stream-limits # nothing else is installed
72
+ pip install "stream-limits[blas]" # threadpoolctl, to limit BLAS after NumPy is loaded
73
+ ```
74
+
75
+ ```{toctree}
76
+ :hidden:
77
+
78
+ threads
79
+ memory
80
+ schedulers
81
+ other-libraries
82
+ reference
83
+ changelog
84
+ ```
@@ -0,0 +1,95 @@
1
+ # Memory limits
2
+
3
+ ## The memory a job has
4
+
5
+ {py:func}`stream_limits.detect_memory_gb` is the memory the process may use. It is read from,
6
+ first match winning:
7
+
8
+ 1. `STREAM_LIMITS_MEMORY_GB`;
9
+ 2. the variables named in `STREAM_LIMITS_MEMORY_VARS` (a size such as `16gb`);
10
+ 3. the cgroup memory limit, the lowest one on the path to the root, so a scheduler's limit on the
11
+ job is found even though the process sits in a child group;
12
+ 4. PBS's own record of the job, `qstat -f $PBS_JOBID` (`Resource_List.mem`, else `vmem`), for
13
+ sites that set no memory variable and enforce no cgroup;
14
+ 5. `SLURM_MEM_PER_NODE`, or `SLURM_MEM_PER_CPU` times the CPUs;
15
+ 6. the machine's physical memory.
16
+
17
+ Use it instead of asking the operating system how much memory there is: on a shared node that
18
+ number is the node's, and a guard of "a quarter of the memory" then allows many times what the
19
+ job has. [Schedulers and containers](schedulers.md) describes each source.
20
+
21
+ ## Polars' memory budget
22
+
23
+ Polars 2.x has an out-of-core memory budget past which its streaming engine spills to disk
24
+ instead of growing. {py:func}`stream_limits.limit_memory` sets it from the memory limit.
25
+
26
+ A streaming sort of 1.9 GB of Parquet peaked at 6.2 GB with Polars' defaults, a multiple of the
27
+ data. With a 400 MB budget it peaked at 1.8 GB, in twice the time. The budget bounds what
28
+ Polars' operators hold, not the whole process: its Parquet readers hold about 1.5 GB more on
29
+ 8 threads (less on fewer), and your own arrays are on top. The default budget is
30
+ `0.5 x (limit - 1.5 GB)`, at least 0.25 GB; change the share with `fraction=` or
31
+ `STREAM_LIMITS_POLARS_FRACTION`.
32
+
33
+ Some things to know:
34
+
35
+ - **It is read once, at import**, like the thread pool, so `limit_memory` must run before Polars
36
+ is imported (a warning says so otherwise).
37
+ - **It is undocumented in Polars 2.x.** The names are those of Polars' binary
38
+ (`POLARS_OOC_MEMORY_BUDGET_MB`, `POLARS_OOC_SPILL_DIR`, ...). So it is set only for Polars 2.0
39
+ or later (read from the package metadata, without importing Polars), never over a budget you
40
+ set yourself, and `STREAM_LIMITS_POLARS_SPILL=0` turns it off.
41
+ - **Where it spills** is Polars' temporary directory. On a cluster, make sure `$TMPDIR` is the
42
+ job's scratch space, or give `spill_dir=` or `STREAM_LIMITS_SPILL_DIR`.
43
+ - **How much it helps depends on the code.** Code that sorts or joins a large Parquet scan
44
+ through Polars' streaming engine gains the most. Code that already works in bounded pieces
45
+ gains little: in hdfe-stream the peak of a 3-million-row fit did not change, and in dpd-stream
46
+ on 4.8 million rows it fell from 2.0 to 1.6 GB, with identical results and no slowdown.
47
+
48
+ ## Choosing between a fast path and a smaller one
49
+
50
+ {py:class}`stream_limits.MemoryBudget` is for code that has a fast path that needs a lot of
51
+ memory and a slower one that needs less, or an allocation that cannot shrink and should be
52
+ refused up front instead of at the operating system's kill.
53
+
54
+ > headroom = limit x (1 - reserve) - memory the process holds now
55
+
56
+ ```python
57
+ from stream_limits import MemoryBudget
58
+
59
+ budget = MemoryBudget(limit_gb=None, reserve=None) # defaults: the job's allocation, 0.15
60
+ need_gb = nnz * 12 / 1e9 # the exact size of the allocation
61
+ if budget.fits(need_gb):
62
+ build_matrix()
63
+ else:
64
+ stream_instead()
65
+ ```
66
+
67
+ - The **limit** defaults to {py:func}`stream_limits.detect_memory_gb`.
68
+ - The **reserve** is the share of the limit held back for what the code does not model:
69
+ allocator slack, transient copies, pages a cgroup charges for files. The default is 0.15
70
+ (`STREAM_LIMITS_MEMORY_RESERVE`). Lower it to run closer to the limit; raise it for a job that
71
+ must not be killed.
72
+ - `headroom_gb(upcoming_gb)` subtracts allocations you know are coming later in the same phase,
73
+ and `fits(need, upcoming, share)` asks about a share of the headroom (half, for per-thread
74
+ copies that must leave room for others).
75
+ - It reads the process's resident memory (one file read on Linux; 0.5 GB is assumed where it
76
+ cannot be read), so the answer reflects what the process holds *at that moment*.
77
+
78
+ Ask whether your **exact** allocation fits, not whether an estimate's upper bound does:
79
+ estimates are pessimistic by design, and refusing a job for an estimate's pessimism helps no one.
80
+
81
+ ## Measuring what was used
82
+
83
+ {py:func}`stream_limits.peak_rss_gb` and {py:func}`stream_limits.reset_peak_rss` read the
84
+ kernel's own high-water mark of the process's resident memory. Nothing is polled, so it costs
85
+ nothing.
86
+
87
+ ```python
88
+ stream_limits.reset_peak_rss()
89
+ fit_the_model()
90
+ print(stream_limits.peak_rss_gb()) # GB, the peak during the call
91
+ ```
92
+
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
95
+ processes are not included. {py:func}`stream_limits.current_rss_gb` is the memory held now.