Skip to content

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.

python3.14t -m pip install mimiq-tensorweaver

There is nothing extra to select. TensorWeaver publishes a dedicated free-threaded wheel and the installer picks it by tag:

Interpreter Wheel
python3.11python3.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:

PYTHON_GIL=0 python3.14t my_simulation.py
# or
python3.14t -X gil=0 my_simulation.py

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:

RuntimeError: Already borrowed

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

export OMP_NUM_THREADS=$((c / n))

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.