Skip to content

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:

export MIMIQ_NUM_THREADS=8      # both pools, eight threads each

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:

  1. that pool's own MIMIQ_* variable,
  2. MIMIQ_NUM_THREADS,
  3. the native variable of the library underneath, which that library reads for itself,
  4. 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:

export MIMIQ_NUM_THREADS=2   # the CPUs the container was given

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}}
  • source names what decided the count: a variable, "limit" while a max_num_threads cap is holding it down, or "unset".
  • threads is the count requested. None means no MIMIQ_* variable applied and the library sized itself; source then 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 between threads and effective.

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.