Free-threaded Python¶
CPython 3.13 introduced a build with no global interpreter lock, and 3.14 is the first release where it is a supported configuration rather than an experiment. On such an interpreter Python threads run at the same time on different cores, so a single process can run several simulations in parallel without the cost of starting one process per run.
TensorWeaver supports it. This page covers how to install the right wheel, the one thing you must currently do to keep the GIL off, the rule your code has to follow, and how much threading actually buys.
Installing¶
Free-threaded interpreters are named with a t suffix: python3.14t. They are
a separate installation from python3.14, and packages are not shared between
them.
There is nothing extra to select. TensorWeaver publishes a dedicated free-threaded wheel and the installer picks it by tag:
| Interpreter | Wheel |
|---|---|
python3.11 … python3.14 |
cp311-abi3, one wheel for every version |
python3.14t |
cp314-cp314t, Linux x86_64: glibc 2.35 or newer, and glibc 2.34 for RHEL 9 and its rebuilds |
The free-threaded wheel exists separately because a free-threaded interpreter has no stable ABI to build against, so it covers exactly one Python version rather than all of them. It carries the same engines as the ordinary wheel: OpenBLAS, MKL and CUDA.
The two wheels cannot be mixed up. A free-threaded interpreter does not accept
abi3 wheels, so asking for one on python3.14t reports no matching
distribution rather than installing something that would crash.
Two limitations for now. The free-threaded wheels are built for Linux x86_64
only; ask us if you need macOS or Windows. And the qiskit extra cannot be
installed, because Qiskit publishes no free-threaded wheels yet.
Keeping the GIL off: PYTHON_GIL=0¶
A free-threaded interpreter turns the GIL back on, for the whole process, as soon as it imports an extension module that has not declared itself free-thread-safe. It says so when it happens:
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
module 'symengine.lib.symengine_wrapper', which has not declared that it can
run safely without the GIL.
TensorWeaver's engines declare it, so importing tensorweaver never causes
this. symengine does not, and mimiqcircuits imports symengine to build
its gate matrices, so import mimiqcircuits re-enables the GIL. Once enabled
it cannot be turned off again: the process keeps it for its lifetime.
Until symengine declares support, override the decision at startup:
Check that it worked before trusting a measurement:
import sys
import mimiqcircuits as mc
import tensorweaver as tw
print(sys._is_gil_enabled()) # must be False
PYTHON_GIL=0 overrides every module's declaration in the process, not just
symengine's, which is why it is opt-in and why we do not enable it for you.
Two things make it a reasonable thing to ask for here. symengine's
free-threading declaration is merged upstream and only waiting on a release, and
its published wheels are already built thread-safe, so the override contradicts
a stale declaration rather than running unprotected code (recorded in
mimiqcircuits 0.27.1's changelog). And we have tested the path you are using:
circuits executed across threads with the GIL forced off return results
identical to a sequential run, including several threads executing the same
Circuit object.
Even so, keep symbolic work out of your threads. Build and parametrise circuits before starting them, which is the normal shape anyway, and the override then covers a library your threads only read from.
One object per thread¶
An MPS, MPO or Rng must be used by one thread at a time. The objects hold
no shared state, so threads that each own theirs never interact. Two threads
driving the same object is refused rather than silently corrupting it:
That is a Rust borrow conflict surfacing in Python, not a data race, but it is an error your code has to avoid rather than handle. Give each thread its own state:
import threading
import tensorweaver as tw
def run(seed, results, index):
sim = tw.TwSimulator(bonddim=256) # one simulator per thread
results[index] = sim.execute(circuit, nsamples=1000, seed=seed)
results = [None] * 8
threads = [
threading.Thread(target=run, args=(seed, results, i))
for i, seed in enumerate(range(8))
]
for t in threads:
t.start()
for t in threads:
t.join()
Sharing the Circuit between threads, as above, is fine: execute reads it
and builds its own state.
The same rule covers a GPU run: a state moved to the device with
MPS.to_device() belongs to the thread that drives it. Threads that each hold
their own device state work, but they share one device, so the parallelism you
get is the device's, not the host's.
What threading buys¶
The answer depends on which API you drive, because the heavy kernels already
release the GIL on every build. MPO application, compression,
recanonicalisation, sampling, expectation values and the noise channels all
release it around the linear algebra, so several threads overlap their work on
an ordinary python3.14 too. Per-gate calls such as apply_gate_2q do not:
the kernel is a single small SVD measured in microseconds, and releasing the
GIL around it would cost more than it saves.
So, for eight concurrent runs on an otherwise idle machine with enough cores:
| What your threads call | python3.14 |
python3.14t |
|---|---|---|
TwSimulator.execute, MPS.apply_mpo, sampling |
already scales, roughly 2 to 3× | slightly better |
MPS.apply_gate_1q / apply_gate_2q in a loop |
no gain, threads take turns | scales, roughly 3 to 4× |
Measured on a random 18-qubit, depth-16 circuit at bond dimension 64, eight threads on 16 cores. Your numbers will differ; the shape is the point. If you are running circuits rather than driving the tensors directly, threading is already working for you and the free-threaded build is a refinement rather than the thing that unlocks it.
Threads and BLAS threads multiply¶
Each simulation thread calls into BLAS, which is itself multi-threaded, so the process can end up asking for far more threads than the machine has cores. Oversubscription of that kind costs much more than the parallelism gains.
Size the two together: with n Python threads on c available cores, set
and never leave BLAS free to use every core in each of n threads. On a
cluster, c is the cores your allocation actually holds, not the cores the
node has. See Tuning a run for the rest of the performance
controls.