Skip to content

feat(pybind): release the GIL around expensive linalg/contract calls#987

Merged
pcchen merged 2 commits into
masterfrom
fix/pybind-gil-release
Jul 8, 2026
Merged

feat(pybind): release the GIL around expensive linalg/contract calls#987
pcchen merged 2 commits into
masterfrom
fix/pybind-gil-release

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Problem

No binding released the GIL, so any long-running call (SVD, eigensolver, contraction, Network.Launch) blocked every other Python thread for its full duration.

Fix

py::call_guard<py::gil_scoped_release>() on 50 binding sites: all O(n³)-class linalg entry points (Svd/Gesvd/±truncate, Rsvd, Eigh/Eig, Qr/Qdr, Hosvd, Lstsq, InvM/InvM_, Matmul(+_dg), Tensordot(+dg), Kron, Dot, Gemm/Gemm, Directsum, Det, Arnoldi, Lanczos, Lanczos_Exp), Contract/Contracts/.contract, and Network.Launch. ExpH/ExpM are deliberately excluded — open PR #915 rewrites those bindings; guarding them here would conflict.

Safety was verified against the fetched pybind11 3.0.1 source, and the rules are recorded in an in-code discipline note at the first guard site: guarded lambda bodies are pure C++ (argument conversion happens before the guard, return conversion after it); py::scoped_ostream_redirect needs the GIL so guard composition order matters; Python LinOp.matvec callbacks are safe because PYBIND11_OVERRIDE reacquires the GIL (verified at pybind11.h:3532) — pinned by an end-to-end test running a real Lanczos ground-state solve through a Python-subclassed LinOp.

Testing

New pytests/binding_gil_test.py: a self-calibrating threading test (asserts a ticker thread keeps running during Svd; red on the unguarded build with gap ≈ full Svd duration), a LinOp smoke test, and the Lanczos-through-Python-LinOp integration test. Elementwise/Tensor-operator sweep deliberately deferred (documented in the in-code note).

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 37.50000% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 32.99%. Comparing base (b4c1584) to head (99ad0a1).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
pybind/linalg_py.cpp 36.61% 0 Missing and 45 partials ⚠️
pybind/unitensor_py.cpp 42.85% 0 Missing and 4 partials ⚠️
pybind/network_py.cpp 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #987      +/-   ##
==========================================
+ Coverage   32.81%   32.99%   +0.17%     
==========================================
  Files         230      230              
  Lines       33039    33069      +30     
  Branches    13769    13781      +12     
==========================================
+ Hits        10841    10910      +69     
+ Misses      14904    14795     -109     
- Partials     7294     7364      +70     
Flag Coverage Δ
cpp 32.63% <37.50%> (+0.18%) ⬆️
python 61.84% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
C++ backend 33.75% <ø> (+0.13%) ⬆️
Python bindings 25.15% <37.50%> (+0.53%) ⬆️
Python package 61.84% <ø> (ø)
Files with missing lines Coverage Δ
pybind/network_py.cpp 22.97% <50.00%> (+1.05%) ⬆️
pybind/unitensor_py.cpp 25.63% <42.85%> (+0.15%) ⬆️
pybind/linalg_py.cpp 21.96% <36.61%> (+2.76%) ⬆️

... and 7 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b4c1584...99ad0a1. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces GIL-release guards (py::call_guard<py::gil_scoped_release>) to several computationally expensive C++ bindings in the pybind/ layer, including SVD, Lanczos, Arnoldi, and tensor contraction operations, allowing other Python threads to run concurrently. It also adds a test suite in pytests/binding_gil_test.py to verify GIL release and callback functionality. The reviewer suggests dynamically scaling the matrix size in the SVD GIL-release test to prevent flaky CI failures on fast machines where a fixed 900x900 matrix might compute too quickly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pytests/binding_gil_test.py Outdated
Comment on lines +33 to +42
a = cytnx.random.normal([900, 900], mean=0.0, std=1.0)

# Measure a single serial Svd call to calibrate the "GIL held too long"
# threshold to this machine's speed instead of a brittle fixed constant.
serial_duration = _time_one_svd(a)
# If a single call is not clearly above the 0.2s jitter floor, the test
# would pass vacuously even with the GIL held; force a resize instead.
assert serial_duration > 0.25, (
"matrix too small to discriminate GIL hold from floor"
)

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.

medium

On highly optimized BLAS/LAPACK configurations (such as multi-threaded Intel MKL or Apple Accelerate on modern hardware), a 900x900 SVD can execute extremely quickly (well under 0.25 seconds). This will cause the assertion assert serial_duration > 0.25 to fail, leading to flaky or broken CI builds on fast machines.

To make the test robust across all hardware and BLAS configurations, we should dynamically scale the matrix size until the SVD takes longer than 0.25 seconds.

    # Dynamically find a matrix size that takes long enough (> 0.25s) on this machine
    # to reliably distinguish GIL release from scheduling jitter.
    size = 600
    a = None
    serial_duration = 0.0
    while size <= 2400:
        a = cytnx.random.normal([size, size], mean=0.0, std=1.0)
        serial_duration = _time_one_svd(a)
        if serial_duration > 0.25:
            break
        size += 300

    assert serial_duration > 0.25, (
        f"Even at {size}x{size}, SVD took only {serial_duration:.3f}s, "
        "which is too small to discriminate GIL hold from floor."
    )

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 9553e00d. The test no longer hard-asserts on a fixed size — it scales the matrix up (1.5x per step from 900, capped at 2000) until a single serial Svd clears a 0.3s discrimination floor. It differs from the suggestion in one way: instead of a hard assert when even the largest size stays under the floor, it pytest.skip()s ("machine too fast to discriminate"), so a very fast LAPACK backend yields a clean skip rather than a red build. Since Svd is O(n^3), one 1.5x bump is ~3.4x the work, so the fast runner that measured 0.22s at 900 clears the floor at 1350. Fresh CI is green everywhere, including BuildAndTest running this test.

@pcchen

pcchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Releases the GIL (py::call_guard<py::gil_scoped_release>()) around 50 expensive linalg/contract/Network.Launch binding sites so long-running C++ calls no longer block other Python threads. A genuinely useful concurrency improvement, and the GIL-safety reasoning is careful and correct.

Correctness — verified

GIL release is only safe under specific conditions; each is handled:

  • Guarded bodies are pure C++. Every guarded site is a function-pointer binding (&cytnx::linalg::Svd, &Network::Launch, &UniTensor::contract, Contract/Contracts, …) or a lambda that just calls a C++ function. None touch py::object/py::args with the GIL released — argument conversion happens before the guard, return conversion after, exactly as call_guard requires. ✅
  • The scoped_ostream_redirect ordering trap is avoided by construction. Every guard is call_guard<gil_scoped_release> alone — never composed with scoped_ostream_redirect (which needs the GIL). I checked the deletions: no site dropped an existing ostream_redirect, so no output-capture regression; the deleted lines are reformatting. The discipline note correctly documents that if a site ever needs both, the redirect must be a manual gil_scoped_release inside the lambda, not appended to the call_guard. ✅
  • The Python-callback path is safe. Lanczos/Arnoldi call back into Python via LinOp.matvec, but PYBIND11_OVERRIDE opens with gil_scoped_acquire before invoking the override (the note cites pybind11.h:3532). A nested acquire-inside-release is the standard, deadlock-free pybind11 pattern, pinned by the end-to-end Lanczos-through-a-Python-subclassed-LinOp test. ✅
  • The in-code discipline note at the first guard site accurately records all three rules for future maintainers — a good, durable artifact.

Notes

CI status

The current red checks are stale, not a code failure: the fast Linux failures (BuildAndTest-ubuntu, DownstreamFindPackage, Formatting, ~15–20s each) are the pre-#1007 "empty ident name" merge-step bug (fatal: empty ident name … exit code 128) — #987's CI ran before #1007 (the git-identity fix) merged. macOS BuildAndTest passed (full build), so the code compiles and its tests pass there. Rebasing onto master (the branch is behind) / re-triggering CI now that #1007 is in will give a real Linux BuildAndTest + the new binding_gil_test.py on the current commit.

Verdict: correct, well-reasoned, well-documented concurrency win — pure-C++ guarded bodies, the ostream_redirect composition trap avoided by construction, and the Lanczos/Arnoldi Python-callback path safe via PYBIND11_OVERRIDE's GIL reacquire. LGTM, pending a fresh CI run (the current reds are the #1007 infra bug, not this PR).

Posted by Claude Code on behalf of @pcchen

@pcchen
pcchen force-pushed the fix/pybind-gil-release branch from c07f1fe to fae306d Compare July 7, 2026 17:02
@pcchen

pcchen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

CI update: the one red job is a flaky test threshold, not a GIL bug

Fresh CI after the rebase is green everywhere except one job — BuildAndTest — which failed at the pytest step on this PR's own new test test_svd_releases_gil, and specifically at its self-calibration guard rather than the actual GIL assertion:

assert serial_duration > 0.25, "matrix too small to discriminate GIL hold from floor"
E   AssertionError: assert 0.21980608199999097 > 0.25

The test times a single serial 900×900 Svd to confirm the matrix is big enough to distinguish "GIL held" from the timing floor, and hard-requires that to exceed 0.25s. On this fast Linux runner it took 0.22s, so the calibration guard trips. Result: 63 passed, 1 failed — the failure is the threshold, not the GIL behavior. The real GIL-release assertion, the LinOp smoke test, and the Lanczos-through-Python-LinOp integration all passed here, and every macOS runner + BuildAndTest-ubuntu-latest passed as well.

So the GIL-release change itself looks correct; the new test is just environment-sensitive — 900×900 isn't reliably above the 0.25s floor on fast runners. Suggest hardening the calibration before merge:

  • scale the matrix up until the serial call reliably clears the floor (the in-code note already mentions "force a resize" — the resize should trigger instead of asserting), or
  • pytest.skip() when the machine is too fast to discriminate, rather than a hard assert failure, or
  • lower the threshold with margin.

Low-severity (test-only), but a test that fails CI intermittently shouldn't merge as-is. Everything else (code review above) stands: LGTM on the GIL fix once the calibration is made robust.

Posted by Claude Code on behalf of @pcchen

@pcchen

pcchen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Pushed a fix for the flaky calibration in test_svd_releases_gil (commit 9553e00d).

Instead of hard-asserting that a single serial Svd on a fixed 900×900 matrix exceeds 0.25s (which failed on a fast runner at 0.22s), the test now scales the matrix up (1.5× dim per step, capped at 2000) until the serial Svd clears a 0.3s discrimination floor, and pytest.skip()s if even the cap stays under the floor (machine too fast to discriminate) rather than failing. Since Svd is O(n³), one 1.5× bump is ~3.4× the work, so the runner that saw 0.22s at 900 will clear 0.3s at 1350; typical/slow runners already clear it at 900 (no resize). The downstream ticker-starvation assertion is unchanged.

Couldn't run it locally (needs a full cytnx build), so it's syntax-checked and relies on the fresh CI to confirm — the discrimination logic is straightforward. The GIL-release code review above is unchanged (LGTM).

Posted by Claude Code on behalf of @pcchen

yingjerkao and others added 2 commits July 8, 2026 16:39
Long-running C++ compute (Svd/Rsvd/Hosvd, Eigh/Eig, Qr/Qdr, InvM/InvM_,
Lanczos/Arnoldi, Tensordot/Kron/Matmul/Dot/Gemm/Det/Directsum/Lstsq,
UniTensor Contract(s)/.contract, Network.Launch) used to hold the GIL
for the whole call, blocking every other Python thread. Each of these
bindings now carries py::call_guard<py::gil_scoped_release>(),
constructed only around the already-materialized C++ invocation
(call_guard's semantics: guard is constructed after argument conversion,
per pybind11/attr.h "T scope_guard; return foo(args...)"), so Python
objects are never touched while the GIL is released.

Scope: all O(n^3)-class linalg entry points EXCEPT ExpH/ExpM (rewritten
by open PR #915; guards there would conflict). The elementwise operator
sweep (tensor_py.cpp arithmetic) is deliberately deferred. A guard
discipline comment block above the first guard in linalg_py.cpp
documents the safety rules (pure-C++ bodies only; left-to-right guard
composition vs scoped_ostream_redirect; trampoline reacquisition);
network_py.cpp and unitensor_py.cpp guard sites point to it.

Guarded bindings (45 sites in linalg_py.cpp, 4 in unitensor_py.cpp,
1 in network_py.cpp):
- linalg_py.cpp: Svd (x2), Gesvd (x2), Rsvd (x3), Gesvd_truncate (x3),
  Svd_truncate (x3), Eigh (x2), Eig (x2), Qr (x2), Qdr (x2), InvM (x2),
  InvM_ (x2), Matmul, Matmul_dg, Det, Tensordot, Tensordot_dg, Kron,
  Dot, Gemm, Gemm_, Directsum, Hosvd (x2), Lstsq, Arnoldi (x2),
  Lanczos (x4), Lanczos_Exp.
- unitensor_py.cpp: UniTensor.contract method, free functions Contract
  (x2 overloads) and Contracts.
- network_py.cpp: Network.Launch.

The Rsvd/Hosvd/Lstsq/InvM/InvM_/Matmul_dg/Tensordot_dg/Gemm_ additions
came from a review-consistency sweep (Gemm was guarded but Gemm_ was
not, etc.); each lambda body was re-audited as pure C++ before guarding
(the only non-trivial case is Rsvd's seed defaulting through
cytnx::random::__static_random_device, a plain std::random_device -
include/random.hpp:25 - with no CPython involvement).

Skipped per plan (not touched, to avoid conflict with open PR #915):
ExpH/ExpM (all overloads) - left untouched.
Not present in this codebase, so not added: Gemm_Batch (no such
binding/overload exists in linalg_py.cpp); Lanczos_Gnd as a separate
top-level binding (only reachable through the "Gnd" method string on
the existing Lanczos overloads, which are already guarded); the
Lanczos_ER/Lanczos_Gnd/Lanczos_Gnd_Ut bindings are commented out
(dead code) in linalg_py.cpp and were left as-is.

Ostream-redirect caution (investigated, does not apply here): none of
the guarded bindings carry a py::scoped_ostream_redirect/
scoped_estream_redirect call_guard, so there is no risk of
gil_scoped_release racing a redirect's construction in this change.
For completeness, verified that IF such a combination were ever needed,
writes would in fact be safe: pybind11's pythonbuf::_sync()
(build_py/_deps/pybind11_sources-src/include/pybind11/iostream.h:96,
inside the `int _sync()` override used by both overflow() and the
destructor) opens with `gil_scoped_acquire tmp;` before touching any
Python object, so C++ writes to a redirected stream reacquire the GIL
themselves. The *construction* of scoped_ostream_redirect itself
(iostream.h:166-171) does touch a Python object (sys.stdout) via a
default argument evaluated at call time in the guard's default
constructor, which is why call_guard's left-to-right construction order
matters if such a combination is ever introduced - gil_scoped_release
must not be constructed first in the chain, or that default-arg Python
attribute lookup would execute without the GIL. This rule is now
captured in the in-code discipline comment.

PYBIND11_OVERLOAD trampoline safety (verified): PyLinOp::matvec
(pybind/linop_py.cpp:29-42) uses PYBIND11_OVERLOAD, which expands via
PYBIND11_OVERRIDE -> PYBIND11_OVERRIDE_NAME -> PYBIND11_OVERRIDE_IMPL
(build_py/_deps/pybind11_sources-src/include/pybind11/pybind11.h:3530-3547,
see also the PYBIND11_OVERLOAD macro itself at line 3640-3641). Line 3532
opens the macro body with `pybind11::gil_scoped_acquire gil;` before
calling get_override() and invoking the Python override. This means a
Python-subclassed LinOp's matvec() can be safely invoked by the solver
even while the surrounding Lanczos/Arnoldi call has released the GIL -
the trampoline reacquires it for the duration of the Python callback.
This justifies guarding the LinOp-taking solvers.

Tests: pytests/binding_gil_test.py
- test_svd_releases_gil: measures a serial single-Svd duration in-test
  and asserts the max ticker gap during 3 concurrent Svd(900x900) calls
  stays under max(0.2s, 0.5 * serial_duration), which is robust across
  machine speeds (a fixed constant threshold was unstable: on this
  machine a 400x400 Svd only takes ~0.08s, under the 0.2s floor, so the
  matrix had to be sized up to ~900x900 to get a single-call duration
  long enough to make GIL-holding observably different from
  GIL-releasing; confirmed red on the baseline build, max_gap
  (~0.645s) matched the measured serial duration (~0.602s) almost
  exactly, i.e. the GIL was fully held). Guards against vacuous passes
  on faster future hardware via an explicit serial_duration > 0.25s
  precondition assert. The ticker thread is daemon=True and stopped in
  a try/finally so a raising Svd cannot leak a thread that would hang
  the run and mask the real error.
- test_lanczos_with_python_linop_still_works: precondition smoke test
  that python LinOp subclassing + matvec() override works at all (no
  solver involved).
- test_lanczos_gnd_with_python_linop: a cheap (<1s) real
  cytnx.linalg.Lanczos(method="Gnd") call through a python-subclassed
  LinOp, mirroring example/DMRG/dmrg_two_sites_dense.py's Hxx pattern,
  exercising the guarded Lanczos binding with the trampoline callback
  under a released GIL.

Full pytest suite: 28 baseline + 3 new = 31 passed, 0 failed.

Co-Authored-By: Claude <noreply@anthropic.com>
…bration

test_svd_releases_gil hard-required a single serial Svd on a fixed 900x900
matrix to exceed 0.25s so the ticker-starvation signal is discriminating.
On fast LAPACK backends a 900x900 Svd can finish in ~0.22s, tripping the
calibration assert (observed on CI: 0.2198s > 0.25 -> AssertionError) even
though the GIL-release behavior itself is correct.

Scale the matrix up (1.5x dim per step, capped at 2000) until the serial
Svd clears a 0.3s discrimination floor, and pytest.skip() if even the cap
stays under the floor (machine too fast to discriminate) rather than failing.
Keeps the assertion meaningful across fast/slow runners without a brittle
fixed size + threshold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao
yingjerkao force-pushed the fix/pybind-gil-release branch from 9553e00 to 99ad0a1 Compare July 8, 2026 08:39

@pcchen pcchen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. The GIL-release change is verified safe: every guarded site is a function-pointer binding or pure-C++ lambda (argument conversion before the guard, return conversion after), no site composes scoped_ostream_redirect with the release (and none dropped an existing redirect), and the Lanczos/Arnoldi Python-callback path is safe via PYBIND11_OVERRIDE's gil_scoped_acquire — pinned by the end-to-end Lanczos-through-a-Python-LinOp test. The in-code guard-discipline note is a valuable durable artifact.

The one CI failure along the way was the PR's own calibration test being environment-sensitive (0.22s Svd on a fast runner vs a 0.25s hard-assert); the adaptive-scaling fix (grow to a 0.3s discrimination floor, skip if the machine is too fast) is now CI-validated — current head is rebased on master with 16/16 checks green, including BuildAndTest running the new binding_gil_test.py.

Posted by Claude Code on behalf of @pcchen

@pcchen
pcchen merged commit 7be09a2 into master Jul 8, 2026
19 checks passed
@pcchen
pcchen deleted the fix/pybind-gil-release branch July 8, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants