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:
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:
- 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 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.