Skip to content

Threads

A run uses two independent thread pools, and they do different jobs.

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, and to the process affinity mask when nothing is set at all. 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

Why two pools

They scale differently, so one number is not always the right answer for both.

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.

What resolves to what

For each pool, in order:

  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 from the process affinity mask.

The native variables are OMP_NUM_THREADS, OPENBLAS_NUM_THREADS and MKL_NUM_THREADS for the core pool, and RAYON_NUM_THREADS for the sampler. They still work, and they are what a MIMIQ_* variable overrides. Prefer the MIMIQ_* names: which of the native ones actually bites depends on which BLAS this build links and how that BLAS was compiled, so OPENBLAS_NUM_THREADS is silently inert on an OpenMP build and none of the three reaches MKL.

The affinity mask is the default deliberately: it is what taskset, a cgroup and a batch scheduler already constrain, so a run inside an allocation sizes itself to the allocation without being told.

Checking what a run resolved

>>> import tensorweaver as tw
>>> print(tw.thread_info())
{
  "core": { "source": "MIMIQ_NUM_THREADS", "threads": 8 },
  "sampler": { "source": "MIMIQ_SAMPLER_NUM_THREADS", "threads": 2 }
}

source names the variable that decided it. A threads of null means no MIMIQ_* variable applied and the library sized itself; source then names the native variable that was set, or unset.

Reach for this when a run does not use the CPUs you expected. A value that is not a positive integer is ignored and the next name in the order is consulted, so a typo shows up here as a source you did not expect rather than 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 is less predictable, because whether threading a contraction helps depends on its size. At bond dimension 512 more core threads clearly win; at bond dimension 256 on 18 qubits a single-threaded BLAS was faster than the default in our measurements. If the evolution dominates your run and you care, measure both.