Threads¶
TensorWeaver runs on threads it does not create itself: the linear algebra runs on the threads of the BLAS or of faer, and sampling runs on a rayon pool. This page covers how many of each a run gets, how to change it, and how to check what a run actually used.
Quick reference¶
| You want | Do |
|---|---|
| One count for everything, for the whole process | export MIMIQ_NUM_THREADS=8 before starting Python |
| Different counts for evolution and sampling | MIMIQ_CORE_NUM_THREADS and MIMIQ_SAMPLER_NUM_THREADS |
| A cap for the runs of one simulator | TwSimulator(max_num_threads=4) |
| To run inside a container or a batch allocation | Set MIMIQ_NUM_THREADS to the CPUs you were given (see Containers and batch jobs) |
| To see what a run resolved | tensorweaver.thread_info() |
The two pools¶
| Pool | What runs on it | Sized by |
|---|---|---|
| core | Linear algebra: gate application, compression, decompositions. | MIMIQ_CORE_NUM_THREADS |
| sampler | Drawing shots from the evolved state. | MIMIQ_SAMPLER_NUM_THREADS |
Both fall back to MIMIQ_NUM_THREADS when their own variable is unset. So the
common case is one variable:
and the uncommon case is two:
export MIMIQ_NUM_THREADS=8 # eight by default
export MIMIQ_SAMPLER_NUM_THREADS=2 # except sampling, which gets two
The pools scale differently, which is why they are separate. The core pool
parallelises inside each tensor contraction, which pays off when the bond
dimension is large and can cost more than it saves when it is small. The
sampler pool parallelises across shots, which pays off whenever you ask for
many shots, whatever the bond dimension. A circuit with a small bond dimension
and ten thousand shots wants few core threads and many sampler threads; a deep
circuit at bond dimension 512 sampled once wants the opposite. If you do not
want to think about it, set MIMIQ_NUM_THREADS and move on.
Set the variables before the first run: the core count is applied when the engine first runs, while the sampler count is read on every sampling call.
Capping one simulator¶
max_num_threads caps both pools for the runs of one simulator, without
touching the environment:
from tensorweaver import TwSimulator
# At most four threads for this simulator's runs, whatever the process has.
sim = TwSimulator(bonddim=128, max_num_threads=4)
results = sim.execute(circuit, nsamples=1000)
TwDirectSimulator takes it too. The cap is a ceiling over what the variables
give: a process sized to two threads stays at two under max_num_threads=4. It
is held for the length of each execute, evolve, evolvezerostate and
expectation call and released when the call returns, so other simulators in
the process are unaffected once it ends.
The pools themselves are process-wide, so two simulators running at the same time on different Python threads share them. Each then runs at the smallest cap either holds: neither gets more than it asked for, and one may get less while the other is running.
What resolves to what¶
For each pool, in order, with any max_num_threads cap applied on top:
- that pool's own
MIMIQ_*variable, MIMIQ_NUM_THREADS,- the native variable of the library underneath, which that library reads for itself,
- nothing, and the library sizes itself.
A value that is not a positive integer is ignored and the next name in the order is consulted.
The native variables depend on the engine (linalg), because each library
reads its own:
| Engine | Core pool reads | Default with nothing set |
|---|---|---|
"openblas" |
OPENBLAS_NUM_THREADS, OMP_NUM_THREADS |
one thread per CPU in the affinity mask |
"mkl" |
MKL_NUM_THREADS, OMP_NUM_THREADS |
one thread per physical core |
"faer" |
RAYON_NUM_THREADS |
one thread per CPU the process may use, counting a CPU quota |
The sampler reads RAYON_NUM_THREADS on every engine. On the faer engine that
one variable therefore sizes both pools, unless a MIMIQ_* variable separates
them.
The native variables keep working, and a MIMIQ_* variable overrides them.
Prefer the MIMIQ_* names: they mean the same thing on every engine, while
OPENBLAS_NUM_THREADS does nothing on MKL and MKL_NUM_THREADS nothing on
OpenBLAS.
Containers and batch jobs¶
The default count comes from the CPU affinity mask, which taskset, numactl
and Slurm's CPU binding all set, so a run inside such an allocation sizes itself
to it without being told.
A CPU quota is different. docker --cpus, a Kubernetes limits.cpu and a
cgroup cpu.max limit how much CPU time the process gets, but leave the
affinity mask at the whole machine, so OpenBLAS and MKL still start one thread
per machine CPU and those threads fight over the quota. faer and the sampler
count the quota and are not affected. Measured under a 2-CPU quota
on a 16-CPU machine, a matrix product ran at 37 GFLOP/s with nothing set and at
104 GFLOP/s with the count set to 2. Under a quota, always set the count:
Never set a count above the CPUs the job was given. An oversubscribed BLAS does not degrade gently: one measurement on 2 CPUs ran in 4.8 s at 2 threads and in 98 s at 8.
Two halves at once¶
Applying an MPO to the state sweeps the chain from both ends toward the middle
(the zip-up, used by "dmpo" and as the first pass of "vmpoa" and
"vmpob"). The two halves are independent, so on the "openblas" and
"faer" engines they run at the same time, each on half of the core count.
This happens on its own once the bond dimension reaches 16, and gave 1.2 to
1.4x on the sweep in our measurements. The "mkl" engine runs the halves one
after the other, at the full count, because splitting did not pay off there.
While the halves run, the core count of the OpenBLAS engine is halved for the whole process, since OpenBLAS has one count per process. Anything else calling into the same engine at that moment, such as a second simulator on another thread, runs on the halved count until the sweep ends.
Checking what a run resolved¶
>>> import tensorweaver as tw
>>> tw.thread_info()
{'core': {'source': 'MIMIQ_NUM_THREADS', 'threads': 8, 'effective': 8},
'sampler': {'source': 'MIMIQ_SAMPLER_NUM_THREADS', 'threads': 2}}
sourcenames what decided the count: a variable,"limit"while amax_num_threadscap is holding it down, or"unset".threadsis the count requested.Nonemeans noMIMIQ_*variable applied and the library sized itself;sourcethen names the native variable that was set, or"unset".effective(core only) is the count the linear algebra actually runs on. A request the library did not take shows up as a difference betweenthreadsandeffective.
tw.thread_info() reports the default engine. Each engine is a separate
library with its own counts, so for a simulator on another engine pass its
name: tw.thread_info(linalg="mkl").
Reach for this when a run does not use the CPUs you expected: a typo in a
variable shows up as a source you did not expect, not as an error.
What to expect from more threads¶
Sampling scales close to linearly with the sampler pool until the shots run out. GHZ on 100 qubits, 4000 shots, on a 16-core machine:
MIMIQ_SAMPLER_NUM_THREADS |
Sampling |
|---|---|
| 1 | 13.5 ms |
| 2 | 7.1 ms |
| 4 | 3.9 ms |
| unset (16) | 1.8 ms |
The core pool only helps once the matrices are large. A 24-qubit brick-layer circuit of depth 20 on a 16-core machine, wall time at 1 thread and at 8:
bonddim |
1 thread | 8 threads |
|---|---|---|
| 16 | 0.06 s | 0.06 s |
| 64 | 0.34 s | 0.45 s |
| 256 | 4.6 s | 3.4 s |
| 512 | 12.9 s | 7.2 s |
Below a bond dimension of about 128 the extra threads are busy but buy nothing, and can cost a little. Gains flatten out around 4 to 8 threads. If the evolution dominates your run and you care, measure both.
To tell "threads are off" from "threads do not help here", compare CPU time to wall time:
import time
t0, c0 = time.perf_counter(), time.process_time()
sim.execute(circuit, nsamples=1)
wall, cpu = time.perf_counter() - t0, time.process_time() - c0
print(f"{cpu / wall:.1f} threads busy on average")
A ratio well above 1 with no speed-up means the threads are running and the problem is too small to use them.
Results are not bitwise identical across thread counts: a different count changes the order of floating-point sums, so fidelities and amplitudes agree to about 1e-13 relative, not to the last bit. Compare runs with a tolerance.