From 7d644334a1d3b240d809fb9c6e298814c9ae5763 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Sun, 31 May 2026 21:27:03 -0500 Subject: [PATCH 01/10] docs(settings): document MULTIPROC_BLAS_THREADS and worker-pool reuse - Add MULTIPROC_BLAS_THREADS to the user-accessible settings table. - Extend the Parallel Processing guide with a "Limiting BLAS threads per worker" section (programmatic cap, threadpoolctl requirement for fork-based workers, no-op cases incl. Apple Accelerate, scipy-OpenBLAS caveat) and a "Worker-pool reuse" section. - Add a changelog entry for MULTIPROC_BLAS_THREADS, pool reuse, and the per-grid-point bare-eigensystem shipping in ParameterSweep. --- docs/source/changelog.rst | 17 ++++++ docs/source/guide/settings/guide-settings.rst | 5 ++ .../guide/settings/ipynb/parallel.ipynb | 54 ++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 29ef137..2749c84 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -12,6 +12,23 @@ Version 4.3.2 **ADDITIONS** + - New setting `scqubits.settings.MULTIPROC_BLAS_THREADS` (int or + `None`, default `None`): caps the number of BLAS/OpenMP threads + per worker process during parallel sweeps (`NUM_CPUS` > 1) to + avoid core oversubscription. The cap is applied only while the + worker pool is created and the parent environment is restored + afterwards. For fork-based workers (the default on Linux/macOS) + this requires the optional `threadpoolctl` package; it has no + effect when numpy's BLAS exposes no thread control (e.g. Apple + Accelerate). A one-time warning is emitted when the cap cannot + take effect. See the :ref:`settings guide `. + - `ParameterSweep` now reuses a single worker pool across the + per-subsystem and dressed sweeps within one run (cached in + `scqubits.settings.POOL` and shut down automatically at + interpreter exit), instead of starting a fresh pool for each, + and ships only the per-grid-point bare eigensystem to each + worker, reducing inter-process serialization on large sweeps. + - Named constructors for `Circuit` from a YAML description: `Circuit.from_yaml_file(path, ...)` (path on disk) and `Circuit.from_yaml_string(yaml_text, ...)` (inline YAML). These diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 1a96115..2fdd9f6 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -34,6 +34,11 @@ scqubits has a few internal parameters that can be changed by the user: +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``NUM_CPUS`` | int | Number of cores to be used in parallelization (default: 1) | +------------------------------+------------------------------+-------------------------------------------------------------------+ +| ``MULTIPROC_BLAS_THREADS`` | int or None (default: None) | Cap BLAS/OpenMP threads per worker process during parallel sweeps | +| | | (``NUM_CPUS`` > 1), avoiding core oversubscription. Needs | +| | | ``threadpoolctl`` for fork-based workers; no effect when numpy's | +| | | BLAS exposes no thread control (e.g. Apple Accelerate). | ++------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_SLICING`` | True / False (default: False)| Whether to enable approximate value-based slicing | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_WARNING`` | True / False (default: True) | Whether to warn user about use of approximate values in slicing | diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index 2310c55..82a6855 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -168,6 +168,58 @@ "scqubits.settings.NUM_CPUS = 6" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Limiting BLAS threads per worker (`MULTIPROC_BLAS_THREADS`)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As noted at the top of this page, Numpy/Scipy internally multi-thread their linear algebra (through the BLAS backend), which competes with process-level parallelization: with `num_cpus` worker processes each spawning a full BLAS thread pool, the cores become oversubscribed and a sweep can run *slower* than with fewer threads per worker.\n", + "\n", + "Besides exporting the thread-count environment variables before import (shown above), scqubits provides a programmatic cap that is applied automatically while the worker pool is created:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scqubits.settings.MULTIPROC_BLAS_THREADS = 1 # e.g. 1, or (physical cores) // num_cpus" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`MULTIPROC_BLAS_THREADS` accepts a positive integer, or `None` (the default) to leave threading untouched. The cap affects only the worker pool created here; the parent process's environment and BLAS thread count are restored once the pool has been built. Whether it actually limits the workers depends on the platform:\n", + "\n", + "- **Spawn-based workers** (the default on Windows) re-read the environment when they re-import Numpy, so the cap applies directly.\n", + "- **Fork-based workers** (the `pathos`/`multiprocessing` default on Linux, and on macOS) inherit the parent's already-initialized BLAS pool and ignore the environment variables. For these the cap requires the optional [`threadpoolctl`](https://github.com/joblib/threadpoolctl) package (`pip install threadpoolctl`); scqubits then reduces the parent's BLAS thread count for the duration of pool creation so the forked workers inherit it.\n", + "- It has **no effect** when Numpy's BLAS exposes no thread control, as with Apple Accelerate on Apple Silicon. Note, however, that Scipy ships its own OpenBLAS on those platforms, so `threadpoolctl` can still cap the threads used by Scipy's eigensolvers — which is what most scqubits diagonalization relies on.\n", + "\n", + "If the cap cannot take effect on your platform, scqubits emits a one-time warning. The optimal combination of `num_cpus` and thread cap is machine- and workload-dependent, so when performance matters it is worth benchmarking a few values.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Worker-pool reuse\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Within a single computation that issues several parallel `map` calls — for example a `ParameterSweep`, which sweeps each bare subsystem and then the dressed system — scqubits caches the worker pool in `scqubits.settings.POOL` and reuses it whenever the requested core count and backend match, instead of starting a fresh pool each time. The cached pool is shut down automatically at interpreter exit. This is transparent and requires no user action.\n" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -222,4 +274,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} From 1cbc49ca3b4a4d26c5a5b124e8ee7535f9b1d010 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:44:17 -0500 Subject: [PATCH 02/10] docs(parallel): add "When does num_cpus help?" break-even section Mirror the expectation-setting from the rewritten demo_multiprocessing notebook: parallelization helps only when grid-size x per-point cost exceeds the per-task overhead, so small/cheap sweeps see no speedup (or a slowdown) and that is normal; note the two usual causes of confusing num_cpus comparisons (below break-even, or BLAS not capped), and that sparse diagonalization is often a bigger lever. --- .../guide/settings/ipynb/parallel.ipynb | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index 82a6855..ca6563c 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -145,6 +145,40 @@ "Once `num_cpus` exceeds the value 1 when passed, scqubits starts a parallel processing pool of the desired number of processes." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## When does `num_cpus > 1` actually help?\n", + "\n", + "Parallelization is **not free**, and for many sweeps it gives no speedup — or even a\n", + "slowdown. Each grid point is shipped to a worker process (pickling + dispatch), and that\n", + "fixed overhead is only worth paying when there is enough work to amortize it:\n", + "\n", + "> `num_cpus > 1` helps only when **(number of grid points) × (cost per point) ≫ the per-task overhead**.\n", + "\n", + "What to expect, therefore:\n", + "\n", + "- **Small grids, or cheap-per-point systems** (small Hilbert spaces, few eigenstates):\n", + " `num_cpus > 1` gives little or no benefit, and is frequently *slower* than serial. This\n", + " is the common case and is entirely normal — keep the default `num_cpus = 1`.\n", + "- **Large grids of expensive points** (large composite Hilbert spaces, many grid points):\n", + " parallel workers pay off.\n", + "\n", + "If a `num_cpus` comparison looks 'inconclusive' or backwards (e.g. `num_cpus=2` slower\n", + "than `num_cpus=1`), the usual causes are (i) the sweep is below this break-even, or (ii)\n", + "BLAS threads were not capped (see below), so the workers oversubscribe the cores. For a\n", + "hands-on demonstration of both regimes, see the `demo_multiprocessing` notebook in the\n", + "[scqubits-examples](https://github.com/scqubits/scqubits-examples) repository; to find the\n", + "fastest configuration on your machine, use `tools/autotune_multiprocessing.py` from the\n", + "source tree.\n", + "\n", + "For large composite systems the per-point **diagonalization method** is often a bigger\n", + "lever than parallelism: sparse diagonalization (the default for large spectra; see\n", + "`AUTO_SPARSE_DIAG`) can be far faster per point, and once each point is cheap, `num_cpus >\n", + "1` helps even less. Try sparse first; parallelize second.\n" + ] + }, { "cell_type": "markdown", "metadata": {}, From 3358f57365e60934c8c55d8ee11188286d0bc9a8 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:27:46 -0500 Subject: [PATCH 03/10] docs(parallel): document the macOS spawn start method and MULTIPROC_START_METHOD - Add a "Process start method (fork vs spawn)" section to the Parallel Processing guide: the platform defaults (fork on Linux, spawn on macOS/Windows), why fork is unsafe on macOS (both Intel and Apple Silicon), the __main__-guard requirement for plain scripts under spawn, the once-per-session (not per-sweep) cost thanks to pool reuse, the override, and the orphaned-worker note. - Add MULTIPROC_START_METHOD to the user-accessible settings table. - Add a 4.3.2 changelog entry. --- docs/source/changelog.rst | 16 +++++ docs/source/guide/settings/guide-settings.rst | 4 ++ .../guide/settings/ipynb/parallel.ipynb | 66 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2749c84..0720fe3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -12,6 +12,22 @@ Version 4.3.2 **ADDITIONS** + - Parallel sweeps now use the ``spawn`` process start method on + macOS (and Windows), and ``fork`` on Linux, configurable via the + new setting ``scqubits.settings.MULTIPROC_START_METHOD``. Fork is + unsafe on macOS -- Apple's Accelerate/GCD and the Objective-C + runtime are not fork-safe, so forking a worker pool after the + numerics have started threads can crash or hang (CPython itself + defaults macOS to ``spawn`` since 3.8; this affects both Intel and + Apple Silicon). With ``spawn``, a plain script that uses + ``num_cpus > 1`` must guard its entry point with + ``if __name__ == "__main__":`` (Jupyter/IPython are unaffected; a + one-time reminder is emitted otherwise). The worker pool is cached + and reused, so the one-time ``spawn`` startup cost is paid once per + session, not per sweep. Set ``MULTIPROC_START_METHOD = 'fork'`` to + restore the previous behavior. See the + :ref:`settings guide `. + - New setting `scqubits.settings.MULTIPROC_BLAS_THREADS` (int or `None`, default `None`): caps the number of BLAS/OpenMP threads per worker process during parallel sweeps (`NUM_CPUS` > 1) to diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 2fdd9f6..8e87741 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -32,6 +32,10 @@ scqubits has a few internal parameters that can be changed by the user: +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``MULTIPROC`` | `str` | 'pathos' (default) or 'multiprocessing' | +------------------------------+------------------------------+-------------------------------------------------------------------+ +| ``MULTIPROC_START_METHOD`` | str or None (default: None) | Worker-process start method: None (platform default), 'fork', | +| | | 'spawn', or 'forkserver'. Default is 'fork' on Linux and | +| | | 'spawn' on macOS/Windows (fork is unsafe on macOS). | ++------------------------------+------------------------------+-------------------------------------------------------------------+ | ``NUM_CPUS`` | int | Number of cores to be used in parallelization (default: 1) | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``MULTIPROC_BLAS_THREADS`` | int or None (default: None) | Cap BLAS/OpenMP threads per worker process during parallel sweeps | diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index ca6563c..b2686c4 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -254,6 +254,72 @@ "Within a single computation that issues several parallel `map` calls — for example a `ParameterSweep`, which sweeps each bare subsystem and then the dressed system — scqubits caches the worker pool in `scqubits.settings.POOL` and reuses it whenever the requested core count and backend match, instead of starting a fresh pool each time. The cached pool is shut down automatically at interpreter exit. This is transparent and requires no user action.\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Process start method (`fork` vs `spawn`)\n", + "\n", + "How worker processes are created — the *start method* — is controlled by\n", + "`scqubits.settings.MULTIPROC_START_METHOD`. The default (`None`) is platform-appropriate:\n", + "\n", + "| platform | default | why |\n", + "|---|---|---|\n", + "| Linux | `fork` | fast, and fork is safe |\n", + "| macOS | `spawn` | fork-after-threads is **unsafe** on macOS — Apple's Accelerate/GCD and the Objective-C runtime are not fork-safe, so forking a worker pool after the numerics have started threads can crash, deadlock, or hang. CPython itself defaults macOS to `spawn` since 3.8. This applies to **both Intel and Apple Silicon** Macs. |\n", + "| Windows | `spawn` | the only option |\n", + "\n", + "You can override it explicitly (for example to force fork on macOS, accepting the\n", + "fork-safety risk):\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scqubits.settings.MULTIPROC_START_METHOD = \"fork\" # or \"spawn\", \"forkserver\"\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### The `__main__` guard (`spawn`/`forkserver` only)\n", + "\n", + "With `spawn` (and `forkserver`), each worker process **re-imports your program's entry\n", + "module**. A **plain script** that triggers `num_cpus > 1` must therefore guard its entry\n", + "point, or the workers would re-run the script and Python raises a `RuntimeError`:\n", + "\n", + "```python\n", + "import scqubits as scq\n", + "\n", + "if __name__ == \"__main__\":\n", + " sweep = scq.ParameterSweep(..., num_cpus=4)\n", + "```\n", + "\n", + "**Jupyter/IPython need no guard.** scqubits emits a one-time warning the first time it\n", + "starts a `spawn` pool outside IPython, reminding you of this requirement.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Cost\n", + "\n", + "`spawn` workers re-import numpy/scipy/scqubits, so the **first** parallel sweep of a\n", + "session pays a one-time startup of roughly a second. Because the pool is cached and\n", + "reused (see *Worker-pool reuse* above), **every subsequent sweep is as fast as fork** — the\n", + "cost is paid once per session, not per sweep. For the heavy sweeps where `num_cpus > 1` is\n", + "worthwhile, this is negligible.\n", + "\n", + "> **Note:** unlike fork children, `spawn` workers are not automatically reaped if the\n", + "> parent process is killed with `SIGKILL` mid-run, and may linger. A normal exit (or a\n", + "> `ParameterSweep.run()` completing) cleans them up.\n" + ] + }, { "cell_type": "markdown", "metadata": {}, From 36441dd66c445a89ee8d7ca6f660a5b8a09b1fb7 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:55:47 -0500 Subject: [PATCH 04/10] docs(parallel): drop MULTIPROC_START_METHOD; start method is platform-determined The start method is no longer a public setting (fork on Linux, spawn on macOS/Windows is the only safe choice per platform). Remove the settings row, rewrite the parallel-guide section to present it as platform- determined rather than configurable, delete the override code cell, and fix the stale "macOS forks" bullet in the BLAS-cap section. --- docs/source/changelog.rst | 35 ++++++++---------- docs/source/guide/settings/guide-settings.rst | 4 -- .../guide/settings/ipynb/parallel.ipynb | 37 ++----------------- 3 files changed, 19 insertions(+), 57 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0720fe3..42e67a9 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -13,31 +13,28 @@ Version 4.3.2 **ADDITIONS** - Parallel sweeps now use the ``spawn`` process start method on - macOS (and Windows), and ``fork`` on Linux, configurable via the - new setting ``scqubits.settings.MULTIPROC_START_METHOD``. Fork is - unsafe on macOS -- Apple's Accelerate/GCD and the Objective-C - runtime are not fork-safe, so forking a worker pool after the - numerics have started threads can crash or hang (CPython itself - defaults macOS to ``spawn`` since 3.8; this affects both Intel and - Apple Silicon). With ``spawn``, a plain script that uses - ``num_cpus > 1`` must guard its entry point with - ``if __name__ == "__main__":`` (Jupyter/IPython are unaffected; a - one-time reminder is emitted otherwise). The worker pool is cached - and reused, so the one-time ``spawn`` startup cost is paid once per - session, not per sweep. Set ``MULTIPROC_START_METHOD = 'fork'`` to - restore the previous behavior. See the - :ref:`settings guide `. + macOS (and Windows), and ``fork`` on Linux. Fork is unsafe on + macOS -- Apple's Accelerate/GCD and the Objective-C runtime are + not fork-safe, so forking a worker pool after the numerics have + started threads can crash or hang (CPython itself defaults macOS + to ``spawn`` since 3.8; this affects both Intel and Apple + Silicon). With ``spawn``, a plain script that uses ``num_cpus > + 1`` must guard its entry point with ``if __name__ == + "__main__":`` (Jupyter/IPython are unaffected; a one-time + reminder is emitted otherwise). The worker pool is cached and + reused, so the one-time ``spawn`` startup cost is paid once per + session, not per sweep. - New setting `scqubits.settings.MULTIPROC_BLAS_THREADS` (int or `None`, default `None`): caps the number of BLAS/OpenMP threads per worker process during parallel sweeps (`NUM_CPUS` > 1) to avoid core oversubscription. The cap is applied only while the worker pool is created and the parent environment is restored - afterwards. For fork-based workers (the default on Linux/macOS) - this requires the optional `threadpoolctl` package; it has no - effect when numpy's BLAS exposes no thread control (e.g. Apple - Accelerate). A one-time warning is emitted when the cap cannot - take effect. See the :ref:`settings guide `. + afterwards. It reaches spawn-based workers (macOS, Windows) via + the thread-count environment variables; for fork-based workers + (Linux) it requires the optional `threadpoolctl` package. A + one-time warning is emitted when the cap cannot take effect. See + the :ref:`settings guide `. - `ParameterSweep` now reuses a single worker pool across the per-subsystem and dressed sweeps within one run (cached in `scqubits.settings.POOL` and shut down automatically at diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 8e87741..2fdd9f6 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -32,10 +32,6 @@ scqubits has a few internal parameters that can be changed by the user: +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``MULTIPROC`` | `str` | 'pathos' (default) or 'multiprocessing' | +------------------------------+------------------------------+-------------------------------------------------------------------+ -| ``MULTIPROC_START_METHOD`` | str or None (default: None) | Worker-process start method: None (platform default), 'fork', | -| | | 'spawn', or 'forkserver'. Default is 'fork' on Linux and | -| | | 'spawn' on macOS/Windows (fork is unsafe on macOS). | -+------------------------------+------------------------------+-------------------------------------------------------------------+ | ``NUM_CPUS`` | int | Number of cores to be used in parallelization (default: 1) | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``MULTIPROC_BLAS_THREADS`` | int or None (default: None) | Cap BLAS/OpenMP threads per worker process during parallel sweeps | diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index b2686c4..4d8714f 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -230,15 +230,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "`MULTIPROC_BLAS_THREADS` accepts a positive integer, or `None` (the default) to leave threading untouched. The cap affects only the worker pool created here; the parent process's environment and BLAS thread count are restored once the pool has been built. Whether it actually limits the workers depends on the platform:\n", - "\n", - "- **Spawn-based workers** (the default on Windows) re-read the environment when they re-import Numpy, so the cap applies directly.\n", - "- **Fork-based workers** (the `pathos`/`multiprocessing` default on Linux, and on macOS) inherit the parent's already-initialized BLAS pool and ignore the environment variables. For these the cap requires the optional [`threadpoolctl`](https://github.com/joblib/threadpoolctl) package (`pip install threadpoolctl`); scqubits then reduces the parent's BLAS thread count for the duration of pool creation so the forked workers inherit it.\n", - "- It has **no effect** when Numpy's BLAS exposes no thread control, as with Apple Accelerate on Apple Silicon. Note, however, that Scipy ships its own OpenBLAS on those platforms, so `threadpoolctl` can still cap the threads used by Scipy's eigensolvers — which is what most scqubits diagonalization relies on.\n", - "\n", - "If the cap cannot take effect on your platform, scqubits emits a one-time warning. The optimal combination of `num_cpus` and thread cap is machine- and workload-dependent, so when performance matters it is worth benchmarking a few values.\n" - ] + "source": "`MULTIPROC_BLAS_THREADS` accepts a positive integer, or `None` (the default) to leave threading untouched. The cap affects only the worker pool created here; the parent process's environment and BLAS thread count are restored once the pool has been built. Whether it actually limits the workers depends on the platform:\n\n- **Spawn-based workers** (macOS and Windows) re-read the environment when they re-import Numpy/Scipy, so the cap applies directly.\n- **Fork-based workers** (Linux) inherit the parent's already-initialized BLAS pool and ignore the environment variables. For these the cap requires the optional [`threadpoolctl`](https://github.com/joblib/threadpoolctl) package (`pip install threadpoolctl`); scqubits then reduces the parent's BLAS thread count for the duration of pool creation so the forked workers inherit it.\n- It has **no effect** when Numpy's BLAS exposes no thread control, as with Apple Accelerate on Apple Silicon. Note, however, that Scipy ships its own OpenBLAS there, so the cap still limits the threads used by Scipy's eigensolvers — which is what most scqubits diagonalization relies on.\n\nIf the cap cannot take effect on your platform, scqubits emits a one-time warning. The optimal combination of `num_cpus` and thread cap is machine- and workload-dependent, so when performance matters it is worth benchmarking a few values." }, { "cell_type": "markdown", @@ -257,30 +249,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Process start method (`fork` vs `spawn`)\n", - "\n", - "How worker processes are created — the *start method* — is controlled by\n", - "`scqubits.settings.MULTIPROC_START_METHOD`. The default (`None`) is platform-appropriate:\n", - "\n", - "| platform | default | why |\n", - "|---|---|---|\n", - "| Linux | `fork` | fast, and fork is safe |\n", - "| macOS | `spawn` | fork-after-threads is **unsafe** on macOS — Apple's Accelerate/GCD and the Objective-C runtime are not fork-safe, so forking a worker pool after the numerics have started threads can crash, deadlock, or hang. CPython itself defaults macOS to `spawn` since 3.8. This applies to **both Intel and Apple Silicon** Macs. |\n", - "| Windows | `spawn` | the only option |\n", - "\n", - "You can override it explicitly (for example to force fork on macOS, accepting the\n", - "fork-safety risk):\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "scqubits.settings.MULTIPROC_START_METHOD = \"fork\" # or \"spawn\", \"forkserver\"\n" - ] + "source": "## Process start method (`fork` vs `spawn`)\n\nHow worker processes are created — the *start method* — is determined by your platform.\nThere is exactly one safe choice per platform, so scqubits selects it automatically; it is\nnot a user setting:\n\n| platform | start method | why |\n|---|---|---|\n| Linux | `fork` | fast, and fork is safe |\n| macOS | `spawn` | fork-after-threads is **unsafe** on macOS — Apple's Accelerate/GCD and the Objective-C runtime are not fork-safe, so forking a worker pool after the numerics have started threads can crash, deadlock, or hang. CPython itself defaults macOS to `spawn` since 3.8. This applies to **both Intel and Apple Silicon** Macs. |\n| Windows | `spawn` | the only option |\n\nThe only consequence you need to be aware of is the `__main__` guard, below." }, { "cell_type": "markdown", @@ -374,4 +343,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} +} \ No newline at end of file From bd5d3c1638b62a3df6f6c34ab3c07a534a93ce78 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:58:29 -0500 Subject: [PATCH 05/10] docs(parallel): document recommend_parallelization + calibrate_parallelization Rewrite the parallel guide around the shipped tuning API: a new "Letting scqubits choose the settings" section covering recommend_parallelization, the num_cpus="auto" sentinel, settings.AUTO_PARALLEL, and the one-time calibrate_parallelization(); drop the dead tools/autotune_multiprocessing.py reference. Add AUTO_PARALLEL and PARALLEL_CALIBRATION_PATH to the settings table, and changelog entries. --- docs/source/changelog.rst | 20 +++++++++++ docs/source/guide/settings/guide-settings.rst | 9 +++++ .../guide/settings/ipynb/parallel.ipynb | 36 ++++--------------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 42e67a9..01d84eb 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -12,6 +12,26 @@ Version 4.3.2 **ADDITIONS** + - New function `scqubits.recommend_parallelization(...)`: a + workload-aware heuristic that picks `num_cpus` and a per-worker + BLAS-thread cap from the Hilbert-space dimension, grid size, + eigenvalue count, and sparse-vs-dense regime. It applies the + choice live (no kernel restart) and starts no worker processes, + so it is safe to call from Jupyter and from plain scripts. + Sweep/spectrum methods accept `num_cpus="auto"` to tune + themselves before running, and `scqubits.settings.AUTO_PARALLEL` + (default `False`) makes unspecified `num_cpus` do the same. See + the :ref:`settings guide `. + + - New function `scqubits.calibrate_parallelization()`: a one-time + measurement that times a short battery of sweeps in isolated + subprocesses and records this machine's per-task overhead, + pool-startup cost, and per-point diagonalization cost to + `~/.scqubits/parallel_calibration.json` (override with + `scqubits.settings.PARALLEL_CALIBRATION_PATH`). When present, the + recommendation uses this measured break-even instead of the + built-in defaults. + - Parallel sweeps now use the ``spawn`` process start method on macOS (and Windows), and ``fork`` on Linux. Fork is unsafe on macOS -- Apple's Accelerate/GCD and the Objective-C runtime are diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 2fdd9f6..0a7ea48 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -39,6 +39,15 @@ scqubits has a few internal parameters that can be changed by the user: | | | ``threadpoolctl`` for fork-based workers; no effect when numpy's | | | | BLAS exposes no thread control (e.g. Apple Accelerate). | +------------------------------+------------------------------+-------------------------------------------------------------------+ +| ``AUTO_PARALLEL`` | True / False (default: False)| When True, sweeps called without an explicit ``num_cpus`` use the | +| | | parallelization heuristic (``recommend_parallelization``) to pick | +| | | ``num_cpus`` and a BLAS-thread cap. Per-call opt-in is also | +| | | available via ``num_cpus="auto"``. | ++------------------------------+------------------------------+-------------------------------------------------------------------+ +| ``PARALLEL_CALIBRATION_PATH``| str or None (default: None) | Location of the one-time machine calibration written by | +| | | ``calibrate_parallelization``. None uses | +| | | ``~/.scqubits/parallel_calibration.json``. | ++------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_SLICING`` | True / False (default: False)| Whether to enable approximate value-based slicing | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_WARNING`` | True / False (default: True) | Whether to warn user about use of approximate values in slicing | diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index 4d8714f..dc9a77a 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -148,36 +148,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## When does `num_cpus > 1` actually help?\n", - "\n", - "Parallelization is **not free**, and for many sweeps it gives no speedup — or even a\n", - "slowdown. Each grid point is shipped to a worker process (pickling + dispatch), and that\n", - "fixed overhead is only worth paying when there is enough work to amortize it:\n", - "\n", - "> `num_cpus > 1` helps only when **(number of grid points) × (cost per point) ≫ the per-task overhead**.\n", - "\n", - "What to expect, therefore:\n", - "\n", - "- **Small grids, or cheap-per-point systems** (small Hilbert spaces, few eigenstates):\n", - " `num_cpus > 1` gives little or no benefit, and is frequently *slower* than serial. This\n", - " is the common case and is entirely normal — keep the default `num_cpus = 1`.\n", - "- **Large grids of expensive points** (large composite Hilbert spaces, many grid points):\n", - " parallel workers pay off.\n", - "\n", - "If a `num_cpus` comparison looks 'inconclusive' or backwards (e.g. `num_cpus=2` slower\n", - "than `num_cpus=1`), the usual causes are (i) the sweep is below this break-even, or (ii)\n", - "BLAS threads were not capped (see below), so the workers oversubscribe the cores. For a\n", - "hands-on demonstration of both regimes, see the `demo_multiprocessing` notebook in the\n", - "[scqubits-examples](https://github.com/scqubits/scqubits-examples) repository; to find the\n", - "fastest configuration on your machine, use `tools/autotune_multiprocessing.py` from the\n", - "source tree.\n", - "\n", - "For large composite systems the per-point **diagonalization method** is often a bigger\n", - "lever than parallelism: sparse diagonalization (the default for large spectra; see\n", - "`AUTO_SPARSE_DIAG`) can be far faster per point, and once each point is cheap, `num_cpus >\n", - "1` helps even less. Try sparse first; parallelize second.\n" - ] + "source": "## When does `num_cpus > 1` actually help?\n\nParallelization is **not free**, and for many sweeps it gives no speedup — or even a\nslowdown. Each grid point is shipped to a worker process (pickling + dispatch), and that\nfixed overhead is only worth paying when there is enough work to amortize it:\n\n> `num_cpus > 1` helps only when **(number of grid points) × (cost per point) ≫ the per-task overhead**.\n\nWhat to expect, therefore:\n\n- **Small grids, or cheap-per-point systems** (small Hilbert spaces, few eigenstates):\n `num_cpus > 1` gives little or no benefit, and is frequently *slower* than serial. This\n is the common case and is entirely normal — keep the default `num_cpus = 1`.\n- **Large grids of expensive points** (large composite Hilbert spaces, many grid points):\n parallel workers pay off.\n\nIf a `num_cpus` comparison looks 'inconclusive' or backwards (e.g. `num_cpus=2` slower\nthan `num_cpus=1`), the usual causes are (i) the sweep is below this break-even, or (ii)\nBLAS threads were not capped (see below), so the workers oversubscribe the cores. For a\nhands-on demonstration of both regimes, see the `demo_multiprocessing` notebook in the\n[scqubits-examples](https://github.com/scqubits/scqubits-examples) repository. Rather than\njudging the break-even by hand, you can let scqubits pick `num_cpus` and the BLAS-thread\ncap for you — see *Letting scqubits choose the settings* just below.\n\nFor large composite systems the per-point **diagonalization method** is often a bigger\nlever than parallelism: sparse diagonalization (the default for large spectra; see\n`AUTO_SPARSE_DIAG`) can be far faster per point, and once each point is cheap, `num_cpus >\n1` helps even less. Try sparse first; parallelize second." + }, + { + "cell_type": "markdown", + "source": "## Letting scqubits choose the settings\n\nRather than picking `num_cpus` and the BLAS-thread cap by hand, you can have scqubits\nrecommend them from the workload. `scqubits.recommend_parallelization` is a *pure*\nfunction — it starts no worker processes, so it is safe to call anywhere — that reads the\nHilbert-space dimension, the number of grid points, the eigenvalue count, and whether\nsparse diagonalization applies, and returns a recommendation:\n\n```python\ncfg = scqubits.recommend_parallelization(hilbertspace=hs, num_points=384, evals_count=20)\nprint(cfg.num_cpus, cfg.blas_threads, cfg.reason)\nsweep = scqubits.ParameterSweep(..., num_cpus=cfg.num_cpus)\n```\n\nBecause a `ParameterSweep` runs as soon as it is constructed, call\n`recommend_parallelization` *before* building the sweep (passing the `HilbertSpace` and the\nintended number of points). More conveniently, pass the sentinel `num_cpus=\"auto\"`, which\nmakes the sweep tune itself **before** it runs:\n\n```python\nsweep = scqubits.ParameterSweep(..., num_cpus=\"auto\")\n```\n\nTo make every sweep that does not specify `num_cpus` tune itself this way, set\n`scqubits.settings.AUTO_PARALLEL = True`. The recommendation applies its choice live (no\nkernel restart) and works the same in Jupyter and in a plain script; only a sweep that the\nheuristic decides to parallelize starts workers, which in a plain script needs the\n`__main__` guard described below.\n\n### Calibrating to your machine (optional, run once)\n\nThe recommendation uses conservative built-in thresholds. For a recommendation tuned to\nyour hardware, run the one-time calibration. It times a short battery of sweeps in isolated\nsubprocesses and measures this machine's per-task dispatch overhead, one-time pool-startup\ncost, and per-point diagonalization cost (dense and sparse):\n\n```python\nscqubits.calibrate_parallelization() # writes ~/.scqubits/parallel_calibration.json\n```\n\nAfterwards `recommend_parallelization` (and `num_cpus=\"auto\"`) automatically use the\nmeasured break-even — parallelizing only once a grid is large enough to repay the measured\npool-startup cost. The calibration runs its measurements as `python -m` subprocesses, so\nthe call itself needs no `__main__` guard. Override the storage location with\n`scqubits.settings.PARALLEL_CALIBRATION_PATH`.", + "metadata": {} }, { "cell_type": "markdown", From 8e46358bd5e22066d621483ef796745f853cc643 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:39:22 -0500 Subject: [PATCH 06/10] docs(diag): document automatic sparse diagonalization (AUTO_SPARSE_DIAG) The three multiprocessing/diagonalization PRs release together, so the docs cover the sparse-diag default alongside the parallelization work already on this branch. - custom_diagonalization notebook: new "Automatic sparse diagonalization" section explaining the default-on behavior (esys/evals_method = None -> sparse eigsh for large spectra with few eigenvalues), the dim/evals gating, the dense fallback on raise or residual-check failure, and how to disable. - settings guide: AUTO_SPARSE_DIAG row added to the settings table. - changelog (4.3.2): automatic sparse diagonalization entry. --- docs/source/changelog.rst | 13 +++++++++++++ docs/source/guide/settings/guide-settings.rst | 6 ++++++ .../ipynb/custom_diagonalization.ipynb | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 01d84eb..a39a6f5 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -62,6 +62,19 @@ Version 4.3.2 and ships only the per-grid-point bare eigensystem to each worker, reducing inter-process serialization on large sweeps. + - Automatic sparse diagonalization: when `esys_method` / + `evals_method` are left at their default (`None`), `scqubits` + now uses sparse `scipy` `eigsh` instead of dense diagonalization + for a large Hamiltonian of which only a small fraction of the + spectrum is requested -- the dressed-spectrum regime of composite + `HilbertSpace` objects -- where it is dramatically faster and + avoids forming the full dense matrix (which may not even fit in + memory). Controlled by `scqubits.settings.AUTO_SPARSE_DIAG` + (default `True`; thresholds `SPARSE_DIAG_MIN_DIM` and + `SPARSE_DIAG_MAX_EVALS_FRAC`); it falls back to the dense solver + if the sparse solver raises or its result fails a residual check. + Set `AUTO_SPARSE_DIAG = False` to always use the dense path. + - Named constructors for `Circuit` from a YAML description: `Circuit.from_yaml_file(path, ...)` (path on disk) and `Circuit.from_yaml_string(yaml_text, ...)` (inline YAML). These diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 0a7ea48..935c5bb 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -48,6 +48,12 @@ scqubits has a few internal parameters that can be changed by the user: | | | ``calibrate_parallelization``. None uses | | | | ``~/.scqubits/parallel_calibration.json``. | +------------------------------+------------------------------+-------------------------------------------------------------------+ +| ``AUTO_SPARSE_DIAG`` | True / False (default: True) | When True, default diagonalization (esys_method/evals_method = | +| | | None) uses sparse scipy eigsh for large spectra where only a few | +| | | eigenvalues are needed, with automatic dense fallback (thresholds | +| | | SPARSE_DIAG_MIN_DIM, SPARSE_DIAG_MAX_EVALS_FRAC). See the | +| | | diagonalization guide. | ++------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_SLICING`` | True / False (default: False)| Whether to enable approximate value-based slicing | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``FUZZY_WARNING`` | True / False (default: True) | Whether to warn user about use of approximate values in slicing | diff --git a/docs/source/guide/settings/ipynb/custom_diagonalization.ipynb b/docs/source/guide/settings/ipynb/custom_diagonalization.ipynb index f580e1d..d75b270 100644 --- a/docs/source/guide/settings/ipynb/custom_diagonalization.ipynb +++ b/docs/source/guide/settings/ipynb/custom_diagonalization.ipynb @@ -51,6 +51,24 @@ "\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Automatic sparse diagonalization\n", + "\n", + "When `esys_method` and `evals_method` are left at their default (`None`), `scqubits` does not always use the same dense solver. For a **large** Hamiltonian of which only a **small fraction** of the spectrum is requested — the typical situation for the dressed spectrum of a composite `HilbertSpace` — it automatically switches to sparse `scipy` `eigsh`, which is dramatically faster than dense diagonalization in this regime (and avoids forming the full dense matrix, which may not even fit in memory).\n", + "\n", + "This is controlled by `scqubits.settings.AUTO_SPARSE_DIAG` (default `True`). Sparse diagonalization is selected only when\n", + "\n", + "- the Hilbert-space dimension is at least `settings.SPARSE_DIAG_MIN_DIM` (default `1000`), **and**\n", + "- the number of requested eigenvalues is at most `settings.SPARSE_DIAG_MAX_EVALS_FRAC` times the dimension (default `0.1`).\n", + "\n", + "Otherwise — and whenever an explicit `esys_method`/`evals_method` is set — the behavior is unchanged. If the sparse solver raises, or its result fails a cheap residual check (a safeguard against the rare case where `eigsh` returns an inaccurate subspace without raising), `scqubits` automatically falls back to the dense solver.\n", + "\n", + "To disable automatic sparse diagonalization and always use the dense path, set `scqubits.settings.AUTO_SPARSE_DIAG = False`." + ] + }, { "cell_type": "markdown", "metadata": {}, From 2e4a687ddf1201c3b27a2e5a0b234f9f450339e2 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:18:48 -0500 Subject: [PATCH 07/10] docs(parallel): document automatic BLAS-thread capping (default "auto") MULTIPROC_BLAS_THREADS now defaults to "auto" (cap each worker to cores // num_cpus), so parallel sweeps no longer oversubscribe the cores out of the box. Update the docs to match: - parallel.ipynb no longer opens with the manual "export thread-count env vars before importing numpy" workaround; it states that capping is automatic and documents the "auto"/int/None values. The break-even diagnostic no longer lists oversubscription as a default failure mode. - guide-settings table and changelog: default "auto", and threadpoolctl is now a runtime dependency rather than optional. --- docs/source/changelog.rst | 24 ++--- docs/source/guide/settings/guide-settings.rst | 11 ++- .../guide/settings/ipynb/parallel.ipynb | 89 +++++++++++-------- 3 files changed, 71 insertions(+), 53 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a39a6f5..56839b7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -45,16 +45,20 @@ Version 4.3.2 reused, so the one-time ``spawn`` startup cost is paid once per session, not per sweep. - - New setting `scqubits.settings.MULTIPROC_BLAS_THREADS` (int or - `None`, default `None`): caps the number of BLAS/OpenMP threads - per worker process during parallel sweeps (`NUM_CPUS` > 1) to - avoid core oversubscription. The cap is applied only while the - worker pool is created and the parent environment is restored - afterwards. It reaches spawn-based workers (macOS, Windows) via - the thread-count environment variables; for fork-based workers - (Linux) it requires the optional `threadpoolctl` package. A - one-time warning is emitted when the cap cannot take effect. See - the :ref:`settings guide `. + - New setting `scqubits.settings.MULTIPROC_BLAS_THREADS` + (`"auto"`, a positive int, or `None`; default `"auto"`): caps the + number of BLAS/OpenMP threads per worker process during parallel + sweeps (`NUM_CPUS` > 1) to avoid core oversubscription. The + default `"auto"` caps each worker to `cores // num_cpus`, so + parallel sweeps no longer oversubscribe the cores out of the box; + a positive int sets a fixed cap, and `None` leaves threading + untouched. The cap is applied only while the worker pool is + created and the parent environment is restored afterwards + (serial work is unaffected). It reaches spawn-based workers + (macOS, Windows) via the thread-count environment variables; for + fork-based workers (Linux) it uses `threadpoolctl` (now a + scqubits dependency). A one-time warning is emitted when the cap + cannot take effect. See the :ref:`settings guide `. - `ParameterSweep` now reuses a single worker pool across the per-subsystem and dressed sweeps within one run (cached in `scqubits.settings.POOL` and shut down automatically at diff --git a/docs/source/guide/settings/guide-settings.rst b/docs/source/guide/settings/guide-settings.rst index 935c5bb..7290ba9 100644 --- a/docs/source/guide/settings/guide-settings.rst +++ b/docs/source/guide/settings/guide-settings.rst @@ -34,10 +34,13 @@ scqubits has a few internal parameters that can be changed by the user: +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``NUM_CPUS`` | int | Number of cores to be used in parallelization (default: 1) | +------------------------------+------------------------------+-------------------------------------------------------------------+ -| ``MULTIPROC_BLAS_THREADS`` | int or None (default: None) | Cap BLAS/OpenMP threads per worker process during parallel sweeps | -| | | (``NUM_CPUS`` > 1), avoiding core oversubscription. Needs | -| | | ``threadpoolctl`` for fork-based workers; no effect when numpy's | -| | | BLAS exposes no thread control (e.g. Apple Accelerate). | +| ``MULTIPROC_BLAS_THREADS`` | "auto", int, or None | Cap BLAS/OpenMP threads per worker during parallel sweeps | +| | (default: "auto") | (``NUM_CPUS`` > 1). Default "auto" caps each worker to | +| | | cores // num_cpus so workers never oversubscribe; an int | +| | | sets a fixed cap; None leaves threading untouched. Uses | +| | | ``threadpoolctl`` (a dependency) for fork-based (Linux) | +| | | workers; no effect when numpy BLAS exposes no thread | +| | | control (e.g. Apple Accelerate). | +------------------------------+------------------------------+-------------------------------------------------------------------+ | ``AUTO_PARALLEL`` | True / False (default: False)| When True, sweeps called without an explicit ``num_cpus`` use the | | | | parallelization heuristic (``recommend_parallelization``) to pick | diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index dc9a77a..bb0c7eb 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -8,41 +8,9 @@ "\n", "Some of the computational tasks performed in scqubits can benefit significantly from parallelization. The scqubits package leverages parallel-processing capabilities provided by the Python Standard Library `multiprocessing` module. For better pickling support, scqubits further supports use of `pathos` and `dill`.\n", "\n", - "One important consideration for parallelization of tasks like parameter sweeps is the fact that Numpy and Scipy tend to make use of multi-threading internally. (Details of that will depend on how they were built on the machine in question.) This will generally lead to competition between multi-threading on the Numpy/Scipy level and parallelization of `map` methods via `multiprocessing` or `pathos`.\n", + "One important consideration for parallelization of tasks like parameter sweeps is the fact that Numpy and Scipy tend to make use of multi-threading internally (through their BLAS backend). With several worker processes each spawning a full BLAS thread pool, the cores become oversubscribed and a sweep can run *slower* than with fewer threads per worker.\n", "\n", - "In many cases, best performance is obtained by limiting the number of threads used by Numpy to \"a few\". (Precise numbers will be machine dependent and need to be determined on a case by case basis.) Limiting this thread number can be achieved from within a Python script or Jupyter and is accomplished by setting environment variables. \n", - "\n", - ".. note::\n", - " Limiting the number of threads will only be effective if environment variables are set before the first import of\n", - " Numpy. \n", - "\n", - "\n", - "Several environment variables can play a role, and which one is needed may again be machine-dependent. \n", - "We thus simply set them all:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "NUM_THREADS = \"1\"\n", - "\n", - "os.environ[\"OMP_NUM_THREADS\"] = NUM_THREADS\n", - "os.environ[\"OPENBLAS_NUM_THREADS\"] = NUM_THREADS\n", - "os.environ[\"MKL_NUM_THREADS\"] = NUM_THREADS\n", - "os.environ[\"VECLIB_MAXIMUM_THREADS\"] = NUM_THREADS\n", - "os.environ[\"NUMEXPR_NUM_THREADS\"] = NUM_THREADS" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "At this point, Numpy import and import of scqubits can proceed." + "scqubits handles this for you: when `num_cpus > 1`, it caps each worker's BLAS threads automatically (via the `MULTIPROC_BLAS_THREADS` setting, default `\"auto\"` -- see *Limiting BLAS threads per worker* below), so in the common case there is nothing to configure. The sections below explain how to enable parallelization, when it actually helps, and the knobs available if you want to tune things by hand." ] }, { @@ -148,7 +116,37 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## When does `num_cpus > 1` actually help?\n\nParallelization is **not free**, and for many sweeps it gives no speedup — or even a\nslowdown. Each grid point is shipped to a worker process (pickling + dispatch), and that\nfixed overhead is only worth paying when there is enough work to amortize it:\n\n> `num_cpus > 1` helps only when **(number of grid points) × (cost per point) ≫ the per-task overhead**.\n\nWhat to expect, therefore:\n\n- **Small grids, or cheap-per-point systems** (small Hilbert spaces, few eigenstates):\n `num_cpus > 1` gives little or no benefit, and is frequently *slower* than serial. This\n is the common case and is entirely normal — keep the default `num_cpus = 1`.\n- **Large grids of expensive points** (large composite Hilbert spaces, many grid points):\n parallel workers pay off.\n\nIf a `num_cpus` comparison looks 'inconclusive' or backwards (e.g. `num_cpus=2` slower\nthan `num_cpus=1`), the usual causes are (i) the sweep is below this break-even, or (ii)\nBLAS threads were not capped (see below), so the workers oversubscribe the cores. For a\nhands-on demonstration of both regimes, see the `demo_multiprocessing` notebook in the\n[scqubits-examples](https://github.com/scqubits/scqubits-examples) repository. Rather than\njudging the break-even by hand, you can let scqubits pick `num_cpus` and the BLAS-thread\ncap for you — see *Letting scqubits choose the settings* just below.\n\nFor large composite systems the per-point **diagonalization method** is often a bigger\nlever than parallelism: sparse diagonalization (the default for large spectra; see\n`AUTO_SPARSE_DIAG`) can be far faster per point, and once each point is cheap, `num_cpus >\n1` helps even less. Try sparse first; parallelize second." + "source": [ + "## When does `num_cpus > 1` actually help?\n", + "\n", + "Parallelization is **not free**, and for many sweeps it gives no speedup -- or even a\n", + "slowdown. Each grid point is shipped to a worker process (pickling + dispatch), and that\n", + "fixed overhead is only worth paying when there is enough work to amortize it:\n", + "\n", + "> `num_cpus > 1` helps only when **(number of grid points) × (cost per point) ≫ the per-task overhead**.\n", + "\n", + "What to expect, therefore:\n", + "\n", + "- **Small grids, or cheap-per-point systems** (small Hilbert spaces, few eigenstates):\n", + " `num_cpus > 1` gives little or no benefit, and is frequently *slower* than serial. This\n", + " is the common case and is entirely normal -- keep the default `num_cpus = 1`.\n", + "- **Large grids of expensive points** (large composite Hilbert spaces, many grid points):\n", + " parallel workers pay off.\n", + "\n", + "If a `num_cpus` comparison looks 'inconclusive' or backwards (e.g. `num_cpus=2` slower\n", + "than `num_cpus=1`), the sweep is most likely below this break-even. (Oversubscription of\n", + "the cores by BLAS threads, historically the other common cause, is avoided by default --\n", + "see `MULTIPROC_BLAS_THREADS` below -- unless you have explicitly set it to `None`.) For a\n", + "hands-on demonstration of both regimes, see the `demo_multiprocessing` notebook in the\n", + "[scqubits-examples](https://github.com/scqubits/scqubits-examples) repository. Rather than\n", + "judging the break-even by hand, you can let scqubits pick `num_cpus` for you -- see\n", + "*Letting scqubits choose the settings* just below.\n", + "\n", + "For large composite systems the per-point **diagonalization method** is often a bigger\n", + "lever than parallelism: sparse diagonalization (the default for large spectra; see\n", + "`AUTO_SPARSE_DIAG`) can be far faster per point, and once each point is cheap, `num_cpus >\n", + "1` helps even less. Try sparse first; parallelize second." + ] }, { "cell_type": "markdown", @@ -191,7 +189,7 @@ "source": [ "As noted at the top of this page, Numpy/Scipy internally multi-thread their linear algebra (through the BLAS backend), which competes with process-level parallelization: with `num_cpus` worker processes each spawning a full BLAS thread pool, the cores become oversubscribed and a sweep can run *slower* than with fewer threads per worker.\n", "\n", - "Besides exporting the thread-count environment variables before import (shown above), scqubits provides a programmatic cap that is applied automatically while the worker pool is created:\n" + "scqubits caps the per-worker BLAS threads automatically while the worker pool is created. The cap is controlled by `MULTIPROC_BLAS_THREADS`, which defaults to `\"auto\"`:" ] }, { @@ -200,13 +198,26 @@ "metadata": {}, "outputs": [], "source": [ - "scqubits.settings.MULTIPROC_BLAS_THREADS = 1 # e.g. 1, or (physical cores) // num_cpus" + "# \"auto\" (default): cap each worker to max(1, cores // num_cpus) -- no oversubscription\n", + "# a positive int : a fixed per-worker cap, e.g. 1 for many small diagonalizations\n", + "# None : opt out and leave the thread environment untouched\n", + "scqubits.settings.MULTIPROC_BLAS_THREADS = \"auto\"" ] }, { "cell_type": "markdown", "metadata": {}, - "source": "`MULTIPROC_BLAS_THREADS` accepts a positive integer, or `None` (the default) to leave threading untouched. The cap affects only the worker pool created here; the parent process's environment and BLAS thread count are restored once the pool has been built. Whether it actually limits the workers depends on the platform:\n\n- **Spawn-based workers** (macOS and Windows) re-read the environment when they re-import Numpy/Scipy, so the cap applies directly.\n- **Fork-based workers** (Linux) inherit the parent's already-initialized BLAS pool and ignore the environment variables. For these the cap requires the optional [`threadpoolctl`](https://github.com/joblib/threadpoolctl) package (`pip install threadpoolctl`); scqubits then reduces the parent's BLAS thread count for the duration of pool creation so the forked workers inherit it.\n- It has **no effect** when Numpy's BLAS exposes no thread control, as with Apple Accelerate on Apple Silicon. Note, however, that Scipy ships its own OpenBLAS there, so the cap still limits the threads used by Scipy's eigensolvers — which is what most scqubits diagonalization relies on.\n\nIf the cap cannot take effect on your platform, scqubits emits a one-time warning. The optimal combination of `num_cpus` and thread cap is machine- and workload-dependent, so when performance matters it is worth benchmarking a few values." + "source": [ + "With the default `\"auto\"`, each worker is capped to `max(1, cores // num_cpus)`, so the workers together use about one thread per core and never oversubscribe. A positive integer sets a fixed per-worker cap (e.g. `1` for many small diagonalizations), and `None` opts out, leaving the thread environment untouched. The cap affects only the worker pool; the parent process's environment and BLAS thread count are restored once the pool has been built, so serial work (`num_cpus = 1`) is never affected.\n", + "\n", + "How the cap reaches the workers depends on the platform:\n", + "\n", + "- **Spawn-based workers** (macOS and Windows) re-read the environment when they re-import Numpy/Scipy, so the cap applies directly.\n", + "- **Fork-based workers** (Linux) inherit the parent's already-initialized BLAS pool and ignore the environment variables. For these, scqubits uses [`threadpoolctl`](https://github.com/joblib/threadpoolctl) (a scqubits dependency) to reduce the parent's BLAS thread count for the duration of pool creation, so the forked workers inherit it.\n", + "- It has **no effect** when Numpy's BLAS exposes no thread control, as with Apple Accelerate on Apple Silicon. Note, however, that Scipy ships its own OpenBLAS there, so the cap still limits the threads used by Scipy's eigensolvers -- which is what most scqubits diagonalization relies on.\n", + "\n", + "If the cap cannot take effect on your platform, scqubits emits a one-time warning; in that case you can fall back to exporting `OMP_NUM_THREADS`/`OPENBLAS_NUM_THREADS` (etc.) in the shell *before* importing Numpy." + ] }, { "cell_type": "markdown", @@ -319,4 +330,4 @@ }, "nbformat": 4, "nbformat_minor": 2 -} \ No newline at end of file +} From 2fc0558f11fa27454ad0df80e8a038e3064e263c Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:14:48 -0500 Subject: [PATCH 08/10] docs(parallel): add existing-user overview, auto/AUTO_PARALLEL mental model, calibration guidance - New top section 'Updating scqubits -- what changes for you?': reassures that existing code is unchanged, explains the automatic BLAS-thread cap, and gives a do-this table + checklist for getting more speed (auto / calibrate / AUTO_PARALLEL). - Rewrote 'Letting scqubits choose the settings' with a 30-second mental model (the switch num_cpus vs the map = calibration data), an explicit num_cpus='auto' vs AUTO_PARALLEL precedence block, and expanded calibration guidance: re-running overwrites the file, and calibrate on an idle, plugged-in machine (battery/CPU-throttling skews the measurements). --- .../guide/settings/ipynb/parallel.ipynb | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index bb0c7eb..807fb67 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -13,6 +13,42 @@ "scqubits handles this for you: when `num_cpus > 1`, it caps each worker's BLAS threads automatically (via the `MULTIPROC_BLAS_THREADS` setting, default `\"auto\"` -- see *Limiting BLAS threads per worker* below), so in the common case there is nothing to configure. The sections below explain how to enable parallelization, when it actually helps, and the knobs available if you want to tune things by hand." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Updating scqubits — what changes for you?\n", + "\n", + "**Will my existing code still work? — Yes, unchanged.** The defaults are the same as before: calculations run on a single core unless you ask for more (`num_cpus`), and every result is numerically identical to what you got previously. If you update and change *nothing*, your scripts and notebooks keep working exactly as they did.\n", + "\n", + "**The one thing that quietly improved (and only helps you).** If you were already passing `num_cpus > 1`, scqubits now automatically limits how many linear-algebra (BLAS) threads each worker uses, so your workers stop fighting over the cores. On many machines this was a hidden trap — adding cores could make a sweep *slower*. That is now handled for you, with identical results. If you used to set thread environment variables by hand to avoid this, you can stop (opt out with `scqubits.settings.MULTIPROC_BLAS_THREADS = None`).\n", + "\n", + "**Want more speed, with less effort?** You no longer have to guess `num_cpus`:\n", + "\n", + "| If you want… | Do this |\n", + "|---|---|\n", + "| speed without thinking | pass `num_cpus=\"auto\"` instead of a fixed number — scqubits sizes the job, picks the workers, and stays serial when parallel wouldn't help |\n", + "| the best choices for *your* machine | run `scqubits.calibrate_parallelization()` **once** — it times your machine and saves the numbers that `\"auto\"` then uses (re-running overwrites them, so you can recalibrate anytime) |\n", + "| every sweep auto-tuned, no kwarg | set `scqubits.settings.AUTO_PARALLEL = True` |\n", + "| to run a plain `.py` script with parallelism | wrap the entry point in `if __name__ == \"__main__\":` |\n", + "\n", + "(How these fit together — and the difference between `\"auto\"` and `AUTO_PARALLEL` — is explained under *Letting scqubits choose the settings* below.)\n", + "\n", + "**What you get:**\n", + "\n", + "- No more accidental slowdowns from thread oversubscription.\n", + "- Sweeps that benefit run faster automatically; sweeps that don't stay serial (no wasted startup).\n", + "- Large composite-system spectra are faster via automatic sparse diagonalization (see `AUTO_SPARSE_DIAG`).\n", + "- Live progress bars during parallel sweeps.\n", + "\n", + "**Checklist:**\n", + "\n", + "1. Update. Run your existing code — it just works.\n", + "2. *(Recommended, once per machine)* `scqubits.calibrate_parallelization()`.\n", + "3. *(Optional)* swap `num_cpus=N` for `num_cpus=\"auto\"`, or set `AUTO_PARALLEL = True`.\n", + "4. *(Scripts only)* guard the entry point with `if __name__ == \"__main__\":`." + ] + }, { "cell_type": "code", "execution_count": 7, @@ -150,7 +186,67 @@ }, { "cell_type": "markdown", - "source": "## Letting scqubits choose the settings\n\nRather than picking `num_cpus` and the BLAS-thread cap by hand, you can have scqubits\nrecommend them from the workload. `scqubits.recommend_parallelization` is a *pure*\nfunction — it starts no worker processes, so it is safe to call anywhere — that reads the\nHilbert-space dimension, the number of grid points, the eigenvalue count, and whether\nsparse diagonalization applies, and returns a recommendation:\n\n```python\ncfg = scqubits.recommend_parallelization(hilbertspace=hs, num_points=384, evals_count=20)\nprint(cfg.num_cpus, cfg.blas_threads, cfg.reason)\nsweep = scqubits.ParameterSweep(..., num_cpus=cfg.num_cpus)\n```\n\nBecause a `ParameterSweep` runs as soon as it is constructed, call\n`recommend_parallelization` *before* building the sweep (passing the `HilbertSpace` and the\nintended number of points). More conveniently, pass the sentinel `num_cpus=\"auto\"`, which\nmakes the sweep tune itself **before** it runs:\n\n```python\nsweep = scqubits.ParameterSweep(..., num_cpus=\"auto\")\n```\n\nTo make every sweep that does not specify `num_cpus` tune itself this way, set\n`scqubits.settings.AUTO_PARALLEL = True`. The recommendation applies its choice live (no\nkernel restart) and works the same in Jupyter and in a plain script; only a sweep that the\nheuristic decides to parallelize starts workers, which in a plain script needs the\n`__main__` guard described below.\n\n### Calibrating to your machine (optional, run once)\n\nThe recommendation uses conservative built-in thresholds. For a recommendation tuned to\nyour hardware, run the one-time calibration. It times a short battery of sweeps in isolated\nsubprocesses and measures this machine's per-task dispatch overhead, one-time pool-startup\ncost, and per-point diagonalization cost (dense and sparse):\n\n```python\nscqubits.calibrate_parallelization() # writes ~/.scqubits/parallel_calibration.json\n```\n\nAfterwards `recommend_parallelization` (and `num_cpus=\"auto\"`) automatically use the\nmeasured break-even — parallelizing only once a grid is large enough to repay the measured\npool-startup cost. The calibration runs its measurements as `python -m` subprocesses, so\nthe call itself needs no `__main__` guard. Override the storage location with\n`scqubits.settings.PARALLEL_CALIBRATION_PATH`.", + "source": [ + "## Letting scqubits choose the settings\n", + "\n", + "**A 30-second mental model.** There are two *separate* pieces, and they work together:\n", + "\n", + "- **The switch — *whether* to parallelize.** That is `num_cpus`: leave it at the default (serial), set a number yourself, or say `\"auto\"` to let scqubits decide.\n", + "- **The map — *how well* `\"auto\"` decides.** `\"auto\"` always works, using built-in rules of thumb. Running `calibrate_parallelization()` once replaces those rules of thumb with real measurements of *your* machine, so `\"auto\"` decides better.\n", + "\n", + "The calibration is **just data** — it does nothing by itself; it only sharpens the choices `\"auto\"` makes. So \"everything optimal\" needs **both**: turn on `\"auto\"` *and* (recommended) calibrate once. The reverse is fine too — `\"auto\"` works without calibrating, just with generic instead of machine-specific numbers.\n", + "\n", + "### Asking for a recommendation\n", + "\n", + "`scqubits.recommend_parallelization` is a *pure* function — it starts no worker processes, so it is safe to call anywhere — that reads the Hilbert-space dimension, the number of grid points, the eigenvalue count, and whether sparse diagonalization applies, and returns a recommendation:\n", + "\n", + "```python\n", + "cfg = scqubits.recommend_parallelization(hilbertspace=hs, num_points=384, evals_count=20)\n", + "print(cfg.num_cpus, cfg.blas_threads, cfg.reason)\n", + "sweep = scqubits.ParameterSweep(..., num_cpus=cfg.num_cpus)\n", + "```\n", + "\n", + "Because a `ParameterSweep` runs as soon as it is constructed, call `recommend_parallelization` *before* building the sweep. More conveniently, pass the sentinel `num_cpus=\"auto\"`, which makes the sweep tune itself **before** it runs:\n", + "\n", + "```python\n", + "sweep = scqubits.ParameterSweep(..., num_cpus=\"auto\")\n", + "```\n", + "\n", + "### `num_cpus=\"auto\"` vs `settings.AUTO_PARALLEL = True` — same engine, different reach\n", + "\n", + "Both trigger the *exact same* auto-tuner; the only difference is *when* it kicks in:\n", + "\n", + "- **`num_cpus=\"auto\"` — per call.** You opt in for one specific sweep; nothing else is affected.\n", + "- **`settings.AUTO_PARALLEL = True` — global default.** Now *any* sweep where you do **not** pass `num_cpus` behaves as if you had written `\"auto\"`. Set it once and forget it.\n", + "\n", + "An explicit number always wins:\n", + "\n", + "```text\n", + "num_cpus=4 -> exactly 4 workers (you decide; auto-tuner not consulted)\n", + "num_cpus=\"auto\" -> auto-tuner decides (always; calibrated if you ran calibrate())\n", + "num_cpus omitted -> auto-tuner if AUTO_PARALLEL=True, otherwise serial (the default)\n", + "```\n", + "\n", + "The recommendation applies its choice live (no kernel restart) and works the same in Jupyter and in a plain script; only a sweep that the heuristic decides to parallelize starts workers, which in a plain script needs the `__main__` guard described below.\n", + "\n", + "### Calibrating to your machine (recommended, run once)\n", + "\n", + "`calibrate_parallelization()` times your hardware — how long it takes to start worker processes, and how expensive a grid point is to diagonalize (dense and sparse) — and saves the result to a small JSON file (`~/.scqubits/parallel_calibration.json` by default; change the location with `settings.PARALLEL_CALIBRATION_PATH`). From then on, every `num_cpus=\"auto\"` decision reads that file and tailors its choice to *your* machine instead of using generic defaults.\n", + "\n", + "```python\n", + "scqubits.calibrate_parallelization() # ~1 minute; measures this machine and writes the file\n", + "```\n", + "\n", + "**Calibrate under realistic, steady conditions — and redo it freely.** The measurement is only as good as the machine state while it runs, and **re-running simply overwrites the previous file**, so recalibrating is cheap and safe. Redo it whenever:\n", + "\n", + "- you accidentally calibrated while the machine was **busy** with other work (the measured costs come out inflated), or\n", + "- a **laptop was on battery / CPU-throttled** at calibration time — many laptops clock down sharply when unplugged, so the calibration over-estimates every cost and `\"auto\"` then plays it too safe (parallelizes less than it should), or\n", + "- you **changed hardware**.\n", + "\n", + "For the most representative numbers, calibrate on an otherwise-idle machine, plugged into wall power. The calibration runs its measurements as `python -m` subprocesses, so the call itself needs no `__main__` guard.\n", + "\n", + "> Tip: if `\"auto\"` ever seems oddly conservative, your calibration may have been taken under load or on battery — just run `calibrate_parallelization()` again on a quiet, plugged-in machine to refresh it." + ], "metadata": {} }, { From 3b340849a0e80a8175b4fa19da5719901cb3df02 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:36:13 -0500 Subject: [PATCH 09/10] docs(parallel): replace update narrative with current-state Quick start --- .../guide/settings/ipynb/parallel.ipynb | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index 807fb67..699b76a 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -17,36 +17,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Updating scqubits — what changes for you?\n", + "## Quick start\n", "\n", - "**Will my existing code still work? — Yes, unchanged.** The defaults are the same as before: calculations run on a single core unless you ask for more (`num_cpus`), and every result is numerically identical to what you got previously. If you update and change *nothing*, your scripts and notebooks keep working exactly as they did.\n", - "\n", - "**The one thing that quietly improved (and only helps you).** If you were already passing `num_cpus > 1`, scqubits now automatically limits how many linear-algebra (BLAS) threads each worker uses, so your workers stop fighting over the cores. On many machines this was a hidden trap — adding cores could make a sweep *slower*. That is now handled for you, with identical results. If you used to set thread environment variables by hand to avoid this, you can stop (opt out with `scqubits.settings.MULTIPROC_BLAS_THREADS = None`).\n", - "\n", - "**Want more speed, with less effort?** You no longer have to guess `num_cpus`:\n", + "Parallelization is opt-in, and scqubits can size it to the job for you. The shortest paths:\n", "\n", "| If you want… | Do this |\n", "|---|---|\n", - "| speed without thinking | pass `num_cpus=\"auto\"` instead of a fixed number — scqubits sizes the job, picks the workers, and stays serial when parallel wouldn't help |\n", - "| the best choices for *your* machine | run `scqubits.calibrate_parallelization()` **once** — it times your machine and saves the numbers that `\"auto\"` then uses (re-running overwrites them, so you can recalibrate anytime) |\n", - "| every sweep auto-tuned, no kwarg | set `scqubits.settings.AUTO_PARALLEL = True` |\n", - "| to run a plain `.py` script with parallelism | wrap the entry point in `if __name__ == \"__main__\":` |\n", - "\n", - "(How these fit together — and the difference between `\"auto\"` and `AUTO_PARALLEL` — is explained under *Letting scqubits choose the settings* below.)\n", - "\n", - "**What you get:**\n", - "\n", - "- No more accidental slowdowns from thread oversubscription.\n", - "- Sweeps that benefit run faster automatically; sweeps that don't stay serial (no wasted startup).\n", - "- Large composite-system spectra are faster via automatic sparse diagonalization (see `AUTO_SPARSE_DIAG`).\n", - "- Live progress bars during parallel sweeps.\n", - "\n", - "**Checklist:**\n", - "\n", - "1. Update. Run your existing code — it just works.\n", - "2. *(Recommended, once per machine)* `scqubits.calibrate_parallelization()`.\n", - "3. *(Optional)* swap `num_cpus=N` for `num_cpus=\"auto\"`, or set `AUTO_PARALLEL = True`.\n", - "4. *(Scripts only)* guard the entry point with `if __name__ == \"__main__\":`." + "| scqubits to choose the settings | pass `num_cpus=\"auto\"` — it sizes the job, picks the workers, and stays serial when parallel wouldn't help |\n", + "| the best choices for *your* machine | run `scqubits.calibrate_parallelization()` once — it times your machine and saves the numbers `\"auto\"` then uses (re-running overwrites them, so you can recalibrate anytime) |\n", + "| every sweep auto-tuned, without a kwarg | set `scqubits.settings.AUTO_PARALLEL = True` |\n", + "| a fixed number of workers | pass `num_cpus=N` |\n", + "| to run a plain `.py` script with parallelism | guard the entry point with `if __name__ == \"__main__\":` |\n", + "\n", + "Each row is explained in the sections below; the difference between `\"auto\"` and\n", + "`AUTO_PARALLEL` is covered under *Letting scqubits choose the settings*." ] }, { From ec5cb18fd36c6040ba47e15ad4cb35f76fe9ed92 Mon Sep 17 00:00:00 2001 From: Jens Koch <19193849+jkochNU@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:55:31 -0500 Subject: [PATCH 10/10] docs(parallel): move setup imports above Quick start so they don't split the prose --- .../guide/settings/ipynb/parallel.ipynb | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/source/guide/settings/ipynb/parallel.ipynb b/docs/source/guide/settings/ipynb/parallel.ipynb index 699b76a..321b9e0 100644 --- a/docs/source/guide/settings/ipynb/parallel.ipynb +++ b/docs/source/guide/settings/ipynb/parallel.ipynb @@ -13,26 +13,6 @@ "scqubits handles this for you: when `num_cpus > 1`, it caps each worker's BLAS threads automatically (via the `MULTIPROC_BLAS_THREADS` setting, default `\"auto\"` -- see *Limiting BLAS threads per worker* below), so in the common case there is nothing to configure. The sections below explain how to enable parallelization, when it actually helps, and the knobs available if you want to tune things by hand." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Quick start\n", - "\n", - "Parallelization is opt-in, and scqubits can size it to the job for you. The shortest paths:\n", - "\n", - "| If you want… | Do this |\n", - "|---|---|\n", - "| scqubits to choose the settings | pass `num_cpus=\"auto\"` — it sizes the job, picks the workers, and stays serial when parallel wouldn't help |\n", - "| the best choices for *your* machine | run `scqubits.calibrate_parallelization()` once — it times your machine and saves the numbers `\"auto\"` then uses (re-running overwrites them, so you can recalibrate anytime) |\n", - "| every sweep auto-tuned, without a kwarg | set `scqubits.settings.AUTO_PARALLEL = True` |\n", - "| a fixed number of workers | pass `num_cpus=N` |\n", - "| to run a plain `.py` script with parallelism | guard the entry point with `if __name__ == \"__main__\":` |\n", - "\n", - "Each row is explained in the sections below; the difference between `\"auto\"` and\n", - "`AUTO_PARALLEL` is covered under *Letting scqubits choose the settings*." - ] - }, { "cell_type": "code", "execution_count": 7, @@ -55,6 +35,26 @@ "from scqubits import HilbertSpace, InteractionTerm, ParameterSweep" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Quick start\n", + "\n", + "Parallelization is opt-in, and scqubits can size it to the job for you. The shortest paths:\n", + "\n", + "| If you want… | Do this |\n", + "|---|---|\n", + "| scqubits to choose the settings | pass `num_cpus=\"auto\"` — it sizes the job, picks the workers, and stays serial when parallel wouldn't help |\n", + "| the best choices for *your* machine | run `scqubits.calibrate_parallelization()` once — it times your machine and saves the numbers `\"auto\"` then uses (re-running overwrites them, so you can recalibrate anytime) |\n", + "| every sweep auto-tuned, without a kwarg | set `scqubits.settings.AUTO_PARALLEL = True` |\n", + "| a fixed number of workers | pass `num_cpus=N` |\n", + "| to run a plain `.py` script with parallelism | guard the entry point with `if __name__ == \"__main__\":` |\n", + "\n", + "Each row is explained in the sections below; the difference between `\"auto\"` and\n", + "`AUTO_PARALLEL` is covered under *Letting scqubits choose the settings*." + ] + }, { "cell_type": "markdown", "metadata": {},