Test with free-threaded Python (3.14t) - #659
Conversation
Hoping that this fixes the issue when installing Python 3.13t on macOS
ecd7545 to
63acff1
Compare
00e60c8 to
55ab962
Compare
Co-Authored-By: Nathan Goldbaum <nathan.goldbaum@gmail.com>
6b1ea74 to
f8bde1e
Compare
Co-Authored-By: Nathan Goldbaum <nathan.goldbaum@gmail.com>
f8bde1e to
a6728cc
Compare
|
Pushed a few updates:
I'll slowly work through the rest of the review comments next! |
|
I've added a lock for the Edit: I discovered that autograd/autograd/differential_operators.py Lines 144 to 160 in 7c6a626 So the fix is not really needed at this time, but I've kept it for later if I ever come back to that part as it makes sense for it to be thread safe anyway. |
pb01ka
left a comment
There was a problem hiding this comment.
Gave final suggestion in #659 (comment). I leave the decision of incorporating it upto you.
ngoldbaum
left a comment
There was a problem hiding this comment.
Just one comment inline. Also are you sure "beta" is right, given the level of work you've put in here? IMO this is more polished than "beta".
| # call _fun.pop(), which would raise an IndexError. The lock is only held | ||
| # during the one-time fill of the cache, so subsequent calls to the cached | ||
| # graph are not blocked by the lock. | ||
| lock = threading.Lock() |
There was a problem hiding this comment.
This works and is safe but it could be a real multithreaded scaling bottleneck if the graph is read from often enough.
Instead, for caches that are read from more often than they are written to, I tend to recommend the CoW pattern: https://py-free-threading.github.io/porting/#copy-on-write.
There was a problem hiding this comment.
Hmm, in this case, the lock is double-checked. We construct the graph once and store it in the cache, so all subsequent reads from the cache skip the lock entirely.
I did some benchmarking:
First benchmark with cached reads on 1 thread with 300k calls
Benchmark 1: dcl 1thr
Time (mean ± σ): 3.275 s ± 0.104 s [User: 3.204 s, System: 0.027 s]
Range (min … max): 3.151 s … 3.429 s 8 runs
Benchmark 2: cow 1thr
Time (mean ± σ): 3.590 s ± 0.314 s [User: 3.399 s, System: 0.057 s]
Range (min … max): 3.293 s … 4.211 s 8 runs
Summary
dcl 1thr ran
1.10 ± 0.10 times faster than cow 1thr
Second benchmark with 8 threads and 100k calls each
Benchmark 1: dcl 8thr
Time (mean ± σ): 6.447 s ± 0.142 s [User: 41.107 s, System: 5.481 s]
Range (min … max): 6.304 s … 6.752 s 8 runs
Benchmark 2: cow 8thr
Time (mean ± σ): 6.430 s ± 0.096 s [User: 41.557 s, System: 5.511 s]
Range (min … max): 6.307 s … 6.589 s 8 runs
Summary
cow 8thr ran
1.00 ± 0.03 times faster than dcl 8thr
That suggests there isn't a major bottleneck in reads so far. With CoW, I faced a different sort of problem – it redundantly retraces the function quite a lot (5.6x on average) across multiple runs of the benchmark, so the CPU cost is wasted due to the races to create the tracing cache.
import autograd.numpy as np
from autograd.misc import const_graph as dcl
from impls import const_graph_cow as cow
# Count how many times the function body is traced when n threads hit the
# first call concurrently
def make(counter):
def loss(x):
counter[0] += 1
return np.sum(np.sin(x) ** 2 + np.exp(x) + np.dot(x, x))
return loss
def measure(cg, n_threads=8, trials=40):
redundant = 0; total_traces = 0
for _ in range(trials):
counter = [0]
f = cg(make(counter))
x = np.ones(8)
b = threading.Barrier(n_threads)
def w():
b.wait(); f(x)
ts = [threading.Thread(target=w) for _ in range(n_threads)]
[t.start() for t in ts]; [t.join() for t in ts]
total_traces += counter[0]
if counter[0] > 1: redundant += 1
return total_traces, trials, redundant
for name, cg in [("DCL", dcl), ("CoW", cow)]:
total, trials, redundant = measure(cg)
print(f"{name}: {total} total traces over {trials} trials x 8 threads "
f"(ideal={trials}). Trials with redundant tracing: {redundant}/{trials}")DCL: 40 total traces over 40 trials x8 threads (ideal=40). Trials with redundant tracing: 0/40
CoW: 223 total traces over 40 trials x8 threads (ideal=40). Trials with redundant tracing: 40/40
(Also, this function isn't currently used directly in the functionality either, so that's a TODO I will be taking a look at after this to implement)
There was a problem hiding this comment.
Benchmarks beat my idle speculation 😀
Haha, I would be happy to bump it up to stable if you insist. But at this time we should note it's still in untested waters, aside from me playing around with the multithreaded tests and my own experiments. Thanks a lot for reviewing! |
Replaces the per-test `rng = npr.RandomState(42)` locals (401 of them) with an `rng` fixture defined once in tests/conftest.py. Under pytest-run-parallel, the new `pytest_run_parallel_get_thread_setups` hook swaps the fixture for a fresh, identically seeded instance in every thread, so each thread reproduces the single-threaded stream. The hookimpl uses optionalhook, so plain pytest runs without the plugin are unaffected. The nox session and the TSAN workflow temporarily install the plugin from the Quansight-Labs/pytest-run-parallel#191 branch. This commit will be reverted once that PR is settled and released. Co-Authored-By: Sebastian Berg <sebastian@sipsolutions.net>
280f1db to
632d68d
Compare
…setups hook" This reverts commit 632d68d.
This PR tries to run our test suite against the free-threaded Python interpreter (Python 3.14t). I'm not testing against Python 3.13t anymore as previous iterations on this PR were doing, because many of the builtins and stdlib support are better in 3.14t, and 3.14t moved out of experimental support following PEP 779.
Such testing helps us check two aspects: (i) bugs in NumPy and SciPy related to free-threading that we should report upstream (I had a few with 3.13t, but fortunately none with 3.14t) and (ii) whether we have anything of concern in our implementation in case there is nothing to report upstream.
In light of (ii), I found a few issues, and I think that I have been able to fix them somewhat:
TraceStackuse aContextVar. This kind of builds from https://py-free-threading.github.io/porting/#converting-global-state-to-thread-local-state.TraceStack.topis a shared counter that every thread increments/decrements. As far as I can tell, this is the only piece of runtime-mutable global state (that I've been able to find). The idea is that concurrent grad/jacobian/forward-mode calls must not induce a race on a globaltopcounter. This was previously usingthreading.local, but I switched toContextVars based on the review below in Test with free-threaded Python (3.14t) #659 (review) (see also Recommendcontextvars.ContextVaras a thread-safe and async-safe mechanism for global state Quansight-Labs/free-threaded-compatibility#337).combo_checkand switch to test parametrization #662). These tests now use a local RNG defined within the test. Note that there are still a few tests that are ill-conditioned/non-smooth operations, which doesn't help, but dropping the globalnpr.randnseems to have fixed quite a lot of issues. I've switched to local RNGs everywhere in the test code, which makes up most of this diff.check_gradsdeterministic by construction, because it draws cotangents from a global RNG. In that sense, I've addedrngarguments toVSpace.randnand itsArrayVSpaceandComplexArrayVSpacecounterparts. Now everycheck_gradscall creates its own freshly seeded generator, and is more reproducible that way. This seems to help quite a bit withtest_odeintandtest_pinv, which are highly susceptible and numerically fragile in my testing. The gradient for thepinvis ill-conditioned for near-singular inputs that a racing global RNG would otherwise produce (and cause the test to be flaky).I also added four explicitly multithreaded tests in
test_thread_safety.py.In this regard, we validate independent concurrent differentiation, i.e., each thread performs its own grad. What we do not support is sharing a single in-flight
Box/traceacross threads, i.e., differentiating between objects that another thread is mutating. That sounds unusual to me. Based on what I have been able to get from reading many of the docs around this stuff, I think NumPy has the same stance, which is to not mutate a shared ndarray across threads. I added some docs in the tutorial, and added a link to the NumPy thread-safety guide.The rest of the changes are all plumbing-related. I added a nox session that runs on free-threaded Python, and another that runs on free-threaded Python with the
pytest-run-parallelplugin. I tested with--parallel-threads 4to match what the NumPy CI is doing for now, but I also tried things locally with--iterations 10. I am not setting that in CI, however.I am more or less following the approach suggested at https://py-free-threading.github.io, which is thankfully written (and written well) by many of my amazing colleagues! <3