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.
- stream_limits-0.1.0/CHANGELOG.md +44 -0
- stream_limits-0.1.0/CITATION.cff +21 -0
- stream_limits-0.1.0/LICENSE.md +21 -0
- stream_limits-0.1.0/MANIFEST.in +5 -0
- stream_limits-0.1.0/PKG-INFO +159 -0
- stream_limits-0.1.0/README.md +114 -0
- stream_limits-0.1.0/docs/_static/.gitkeep +0 -0
- stream_limits-0.1.0/docs/changelog.md +2 -0
- stream_limits-0.1.0/docs/conf.py +59 -0
- stream_limits-0.1.0/docs/index.md +84 -0
- stream_limits-0.1.0/docs/memory.md +95 -0
- stream_limits-0.1.0/docs/other-libraries.md +190 -0
- stream_limits-0.1.0/docs/reference.md +67 -0
- stream_limits-0.1.0/docs/schedulers.md +142 -0
- stream_limits-0.1.0/docs/threads.md +83 -0
- stream_limits-0.1.0/pyproject.toml +61 -0
- stream_limits-0.1.0/setup.cfg +4 -0
- stream_limits-0.1.0/stream_limits/__init__.py +831 -0
- stream_limits-0.1.0/stream_limits/__main__.py +7 -0
- stream_limits-0.1.0/stream_limits/py.typed +0 -0
- stream_limits-0.1.0/stream_limits.egg-info/PKG-INFO +159 -0
- stream_limits-0.1.0/stream_limits.egg-info/SOURCES.txt +25 -0
- stream_limits-0.1.0/stream_limits.egg-info/dependency_links.txt +1 -0
- stream_limits-0.1.0/stream_limits.egg-info/requires.txt +20 -0
- stream_limits-0.1.0/stream_limits.egg-info/top_level.txt +1 -0
- stream_limits-0.1.0/tests/test_limits.py +501 -0
- stream_limits-0.1.0/tests/test_override_and_libraries.py +215 -0
|
@@ -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,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,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.
|