Thread limits¶
Polars, numba, BLAS and pyarrow each keep their own thread pool, and each defaults to every core of the machine. A job allocated 8 of a node’s 64 cores that imports all four can run 64 threads and be killed for it.
What each library allows¶
Library |
Can it be capped after import? |
What |
|---|---|---|
Polars |
No: the pool is sized once, at import (Polars 1.37: at first use) |
sets |
numba |
down, never above |
sets the variable; lowers |
BLAS (NumPy, SciPy) |
yes, through |
sets |
Apple Accelerate (NumPy on macOS) |
No: |
sets |
pyarrow |
yes |
|
numexpr, PyTorch |
yes |
|
OpenMP programs, TensorFlow, rayon, joblib, Julia |
by environment variable, before they start |
|
Other libraries lists everything covered, what was checked against the library and what was not, and has recipes for libraries whose limits belong to an object (DuckDB, Dask, Ray, …).
So the only reliable way to cap everything is to set environment variables before the
libraries are imported. stream_limits.limit_threads() does that, and then lowers what
can still be lowered in libraries that are already loaded. (stream_limits.set_limits()
does this and the memory limit in one call.)
import stream_limits
stream_limits.limit_threads(8) # or limit_threads(): the job's allocation
import polars as pl # now capped at 8
If Polars was imported first, limit_threads still lowers numba, BLAS and pyarrow, cannot
touch Polars, and says so in a RuntimeWarning that names the fix. The fix is the environment
variable, set in the job script where it is in place before any Python starts:
export STREAM_LIMITS_MAX_THREADS=$NCPUS
The call returns what it did (set_env, kept, lowered, too_late), for a log line.
Variables you have set¶
By default stream-limits does not override a variable you have set. If OMP_NUM_THREADS=1
is in your environment (to keep BLAS single-threaded, say), limit_threads(8) leaves it at 1, lists
it under kept in the report, and sets the others. Libraries that are already loaded are only ever
lowered: a cap is a ceiling, so one you set lower yourself stays.
The risk is the opposite one, a stale variable above the cap (an OMP_NUM_THREADS=64 in a
.bashrc). So a variable you set above the cap is left alone and a warning names it:
RuntimeWarning: OMP_NUM_THREADS=64 is set above the thread limit 8 and left alone; pass
override=True (or unset it) to apply the limit.
override=True replaces the variables and sets loaded libraries to exactly the cap (numba cannot go
above its import-time maximum). The same holds for the memory side: a Polars budget or spill
directory you have set is kept unless override=True. STREAM_LIMITS_MAX_THREADS and
STREAM_LIMITS_MEMORY_GB are the exception: they are this library’s own, and an argument you give
always wins over them.
For one block of code¶
stream_limits.thread_scope() holds numba and BLAS to n threads inside a block and
restores them after, also when the block raises. Polars is not affected: its pool is fixed (at import, or at first use in Polars 1.37) for
the process.
with stream_limits.thread_scope(2):
result = heavy_numba_function(data)
How many threads?¶
With no argument, limit_threads uses stream_limits.detect_cpus(): the job’s CPU
allocation, read from the environment, cgroups or the scheduler (see
Schedulers and containers), and otherwise the machine’s. A job that runs several
processes should give each its share, for example
STREAM_LIMITS_MAX_THREADS=$(( NCPUS / 4 )) for four.
How OpenMP threads wait¶
OpenMP threads that finish a parallel region either spin, waiting for the next one, or
sleep. Spinning is quicker to resume, but a program that makes many short parallel calls
(numba’s prange loops, one per batch of data) can run several times slower with it,
notably on CPUs that mix performance and efficiency cores, and a little slower on others.
Programs with few, long parallel regions are unaffected: hdfe-stream’s solvers and
logit-stream’s fits ran as fast either way.
stream_limits.set_openmp_wait_policy() sets OMP_WAIT_POLICY (to "passive" unless you
say "active"). A package opts in by calling it, because the setting reaches every
OpenMP library in the process (BLAS builds on OpenMP, scikit-learn, LightGBM); importing
stream-limits, and apply_scheduler_defaults, leave it alone. A value you have set is kept unless
you pass override=True. The runtime reads the variable when it loads, which for numba is
at its first parallel call, so call the function before that. The result says when a runtime
was already loaded (Linux), in which case the variable is set for child processes only.
On Windows, numba uses Microsoft’s OpenMP runtime, which does not read the variable, so the
call changes nothing there.