Skip to content

fix: apply the automatic BLAS thread cap without threadpoolctl, and on Sweep - #110

Merged
tschm merged 1 commit into
mainfrom
fix/automatic-blas-thread-cap
Aug 23, 2026
Merged

fix: apply the automatic BLAS thread cap without threadpoolctl, and on Sweep#110
tschm merged 1 commit into
mainfrom
fix/automatic-blas-thread-cap

Conversation

@tschm

@tschm tschm commented Aug 23, 2026

Copy link
Copy Markdown
Member

Fixes #106. Fixes #107.

Two defects in the automatic BLAS thread cap. They land together because the
second cannot be fixed without the first: adding the cap to Sweep as
solve_qp installed it would have propagated #106's crash to a second API.

#106 — an optional dependency reached from a path nobody opted into

solve_qp consults auto_cap_threads whenever blas_threads is omitted, then
enters _threads.limit(...), which raises when threadpoolctl — an optional
extra — is absent. So on a plain pip install cvx-quadprog, a process on Linux
with NumPy against OpenBLAS and more threads configured than physical cores
raised ImportError from every solve at or above dynamic_n_thresh() (256
on the 512 KB fallback, 128 with fast=True), on a call that never mentioned
threading, with a message naming the blas_threads the caller had not passed.

The module docstring already stated the principle the code then broke — the
probes are written in stdlib and NumPy alone "so that they work on a plain
pip install of this package, which is exactly the installation the trap is set
for." The probes honoured that; the cap they fed turned the extra into a hard
requirement on precisely that installation, and turned a performance defence
into an availability failure.

What was missing is that the two callers of limit want opposite failure modes:

  • blas_threads=N is a request. A missing dependency must keep raising —
    silently not capping is not what the caller asked for. Unchanged.
  • The automatic cap is a defence nobody asked for. A missing dependency now
    makes _auto_cap_target decline, which leaves the process exactly as it was
    before the gate existed, with one warning per process pointing at
    OPENBLAS_NUM_THREADS — which caps the whole process and needs nothing
    installed.

Availability is probed with the same from threadpoolctl import threadpool_limits
that limit performs, rather than a find_spec check, so the two cannot
disagree: whatever makes limit raise makes the gate decline. It is uncached
because its only caller is _auto_cap_target, which is — so the import is
attempted once per process either way, and a second cache would only be another
thing for a test to have to clear.

This was not reachable on the maintainers' machines (Accelerate exits at the
platform.system() test), which is why the suite passed at 100% branch coverage
with the defect in place.

#107Sweep had no guard at all

Sweep calls _solve_with_factors directly, below the level solve_qp
installs the context at, and _sweep.py never imported _threads. So the API
most exposed to the measured 73x collapse — large problems, solved repeatedly —
was the one path with no protection, and the cost was amplified rather than
equal: a solve_qp caller eats one bad solve, a Sweep caller eats it on
__init__, on the first solve, and on every miss thereafter.

Sweep now consults the same gate once per object, since n is fixed for
its lifetime and so is the gate's answer, and applies it to the two blocks that
do O(n^3)-or-worse BLAS work: the factorisation in __init__ and the miss
branch of solve.

Cache hits are deliberately left unwrapped. A hit is an O(nk) recovery plus
a KKT check, and threadpoolctl costs ~100 µs — wrapping it would tax exactly
the path the class exists to make cheap. Pushing the context down into
_solve_with_factors instead would have done precisely that, which is why it is
at the call sites.

blas_threads= is exposed on Sweep.__init__ with the meaning it has on
solve_qp — an explicit count used as given, bypassing the gate. It is
validated at construction rather than at the first solve, because the
factorisation is inside the cap.

Verification

The #106 regression, reproduced before and asserted after — a faked
oversubscribed machine with threadpoolctl hidden as a plain install would have
it. Previously ImportError; now the solve returns the right answer, with one
warning:

solved: True True
warnings: 1
  RuntimeWarning This process is configured for 16 BLAS threads on 8 physical cores, ...

Where the cap lands for Sweep, on the same faked machine:

after __init__      : [8]          # the O(n^3) factorisation
after first (miss)  : [8, 8]
after second (hit)  : [8, 8]       # a hit is not wrapped
after third (miss)  : [8, 8, 8]
small-n sweep       : []           # gate declines: no context at all
explicit=2          : [2, 2]       # bypasses the gate
blas_threads=0      : ValueError at construction

Every answer matches a cold solve_qp — this is a performance knob on this path
too, and must be nothing else.

make all is green: 1143 tests, 100% of statements and branches on all ten
modules including the new code, ty and mypy --strict clean, interrogate at
100%, make rhiza-test passing.

Two changes beyond the two fixes

  • pytest.importorskip("threadpoolctl") on two existing tests.
    test_auto_cap_threads_behavior and test_solve_qp_automatically_caps_large_problems
    assert that a cap is applied, which now depends on the dependency being
    importable. Without the skip they would fail confusingly on an install that
    lacks it — a direct consequence of this change, so it is fixed here.
  • README. The BLAS-threads section documented only environment variables and
    manual threadpoolctl wrapping; it never mentioned the automatic cap at all,
    and the install note named only solve_qp. Both brought up to date, including
    what happens when threadpoolctl is absent.

Not done here

No performance numbers moved, so nothing in README.md or docs/paper/ needed
re-measuring under rule 3: the cap changes a thread count, not an algorithm, and
the hit path is untouched by design.

One commit rather than two, deliberately. Splitting it per issue would have left
scoped_limit unreferenced in the first commit and failed the 100% coverage
gate, so a bisectable green history won over two changelog entries. Say if you
would rather have the two lines and I will restructure.

🤖 Generated with Claude Code

…n Sweep

Two defects in the automatic cap, fixed together because the second cannot
land without the first.

`_auto_cap_target` now declines when `threadpoolctl` cannot be imported,
warning once per process and naming `OPENBLAS_NUM_THREADS`. Before, a plain
`pip install cvx-quadprog` on Linux against OpenBLAS with more threads
configured than physical cores raised `ImportError` from every `solve_qp` at
or above `dynamic_n_thresh()` -- 256 on the fallback L2, 128 with `fast=True`
-- on a call that never mentioned threading, with a message naming the
`blas_threads` the caller had not passed. An opt-in extra was effectively
mandatory on exactly the installation the stdlib-only probes exist to
support. The gate is a defence nobody asked for, so a missing dependency now
means no cap rather than no solve; an explicit `blas_threads=` still raises,
because quietly not honouring a request is worse than saying so (#106).

`Sweep` now consults the same gate, once per object since its `n` is fixed,
and applies the answer to the `O(n^3)` factorisation in `__init__` and to
every cache miss. It called `_solve_with_factors` directly, below the level
`solve_qp` installs the context at, so the API most exposed to the 73x
collapse -- large problems, solved repeatedly -- was the one path with no
guard. Cache hits are deliberately left unwrapped: threadpoolctl costs
~100 us against an `O(nk)` recovery. `blas_threads=` is exposed on
`Sweep.__init__` with the meaning it has on `solve_qp`, validated at
construction because the factorisation is inside the cap (#107).

Coverage stays at 100% of statements and branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 23, 2026 10:03
# O(n^3), so it is inside the cap. A bad `blas_threads` therefore raises
# here, at construction, rather than at the first solve.
with _threads.scoped_limit(self._blas_threads):
self._Rinv, _xu = _factorize(G, np.zeros(self.n), False)
@tschm
tschm merged commit f0de98f into main Aug 23, 2026
44 checks passed
@tschm
tschm deleted the fix/automatic-blas-thread-cap branch August 23, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes two threading-related defects in the BLAS thread auto-capping logic: it prevents the automatic cap from turning the optional threadpoolctl dependency into a hard runtime requirement, and it extends the same protection to the Sweep API (without wrapping its cache-hit hot loop).

Changes:

  • Make the automatic cap degrade to “no cap + warn once” when threadpoolctl is unavailable, while keeping explicit blas_threads= as a hard requirement.
  • Add blas_threads support and automatic-cap application to Sweep (factorisation + cache misses, not hits).
  • Update tests to account for optional availability of threadpoolctl, and extend coverage for the new behaviors.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/test_threads.py Adds skips when threadpoolctl is absent and adds regression/behavior tests for the auto-cap fallback and Sweep capping behavior.
src/cvx/quadprog/_threads.py Adds threadpoolctl availability probing for the automatic cap, warns-and-declines when unavailable, and introduces scoped_limit().
src/cvx/quadprog/_sweep.py Adds blas_threads parameter and applies the cap to the factorisation and miss-path solves only.
README.md Documents Sweep threading support and describes the automatic cap’s behavior and optional dependency behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +264 to +266
f"or set OPENBLAS_NUM_THREADS={cores} to cap the whole process instead.",
RuntimeWarning,
stacklevel=2,
Comment thread README.md
Comment on lines +435 to +436
solve at `n = 10`. `blas_threads=` on `solve_qp` and on `Sweep` does the same
thing per call, and is used exactly as given.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants