From 17cb6b023c97cd0515806072862282eb95eb951b Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sun, 30 Aug 2026 14:36:59 +0000 Subject: [PATCH 1/3] Allow parallel CPU IR FD loop when GPU is available Remove the gpu4pyscf veto from QUANTUI_FREQ_PARALLEL gating so HPC nodes with one GPU and many cores can fan displaced SCFs to CPU workers while reference SCF and Hessian still use the GPU. Document the env var and NCShare tradeoff in CLI, help, and copilot instructions. Co-authored-by: Jonathan Schultz --- .github/copilot-instructions.md | 1 + docs/CLI.md | 1 + quantui/calc_log.py | 6 ++--- quantui/freq_calc.py | 13 +++++----- quantui/freq_ir_workers.py | 36 +++++++++++++------------- quantui/help_content.py | 6 ++++- tests/test_est_frequency_cost_model.py | 23 ++++++---------- tests/test_freq_ir_workers.py | 29 ++++++++++----------- 8 files changed, 55 insertions(+), 60 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 65764a6..0ea95e0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -678,6 +678,7 @@ Install all runtime + dev extras: `pip install -e ".[pyscf,ase,app,notebook,dev] | --- | --- | --- | | `QUANTUI_RESULTS_DIR` | `./results` | Where calculation results are saved | | `QUANTUI_LOG_DIR` | `~/.quantui/logs` | Where perf_log and event_log live | +| `QUANTUI_FREQ_PARALLEL` | (unset / off) | When truthy (`1`, `true`, `yes`, `on`), fan out IR-intensity finite-difference SCFs across CPU worker processes in `freq_calc.py` (via `freq_ir_workers.py`). Reference SCF + Hessian still use gpu4pyscf when available; workers are CPU-only. Requires ≥4 cores and ≥2 atoms. Useful on NCShare-style nodes (one GPU, many cores). | | `QUANTUI_SETTINGS_PATH` | `~/.quantui/settings.json` | User-preferences file (`user_settings.py`); override for tests | --- diff --git a/docs/CLI.md b/docs/CLI.md index 74a7018..2ab5aa7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -251,6 +251,7 @@ successfully; only the auto-open is best-effort. | --- | --- | | `QUANTUI_LOG_DIR` | Override the default `~/.quantui/logs/` location. The dashboard's default output (`~/.quantui/dashboard.html`) follows: it lives one level up from the active `QUANTUI_LOG_DIR`. | | `QUANTUI_DISABLE_GPU` | Force CPU mode even when gpu4pyscf is installed. `quantui gpu check` reports this as the reason. Accepted truthy values: `1`, `true`, `True`. | +| `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Reference SCF and Hessian still use gpu4pyscf when available; only the displacement loop fans out to processes. Useful on HPC nodes with one GPU and many cores (e.g. NCShare). Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. | --- diff --git a/quantui/calc_log.py b/quantui/calc_log.py index e1a1238..fc8e8db 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -669,9 +669,8 @@ def _estimate_frequency_cost( - ``ir_intensity_term`` — the 6N inner SCFs that compute ∂μ/∂R for IR intensities, divided by ``effective_workers`` when the ``QUANTUI_FREQ_PARALLEL`` cross-displacement worker pool is gated - on (requires no GPU + ≥4 cores + ≥6 displacements). On a GPU host - the inner SCFs are already accelerated by gpu4pyscf, so parallel - adds little and stays serial. + on (requires opt-in env var + ≥4 cores + ≥6 displacements). Workers + are CPU-only even when the reference SCF used gpu4pyscf. Returns ``None`` when the SP anchor can't be produced (no usable history for the SP profile). In that case ``estimate_time``'s @@ -739,7 +738,6 @@ def _estimate_frequency_cost( if parallel_enabled_for_run( cpu_count=cpu_count, displacement_count=displacement_count, - gpu_available=bool(gpu_used), ): effective_workers = pick_worker_count(cpu_count, displacement_count) except Exception: # noqa: BLE001 — gating is best-effort diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 28fd6a3..7c07b8b 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -620,21 +620,20 @@ def _displaced_scf_dipole() -> _np_ir.ndarray: ) # Opt-in parallel path (Pass B). When (a) the user has - # set ``QUANTUI_FREQ_PARALLEL=1``, (b) no GPU is available, - # (c) the host has >= 4 cores, and (d) the molecule has >= 2 - # atoms, we fan the per-displacement SCFs out across a - # ProcessPoolExecutor. The decision is centralised in + # set ``QUANTUI_FREQ_PARALLEL=1``, (b) the host has >= 4 + # cores, and (c) the molecule has >= 2 atoms, we fan the + # per-displacement SCFs out across a ProcessPoolExecutor + # (CPU workers — see freq_ir_workers). Without the env var, + # displaced SCFs stay serial and may use gpu4pyscf when + # available. The decision is centralised in # ``freq_ir_workers.parallel_enabled_for_run`` so tests # can pin the contract. from quantui import freq_ir_workers as _ir_par - from quantui.gpu_offload import is_gpu_available - _gpu_ok, _ = is_gpu_available() _cpu_count = os.cpu_count() or 1 _use_parallel = _ir_par.parallel_enabled_for_run( cpu_count=_cpu_count, displacement_count=_ir_total_solves, - gpu_available=_gpu_ok, ) _mol_v = mol.verbose diff --git a/quantui/freq_ir_workers.py b/quantui/freq_ir_workers.py index 0b17e5d..37af221 100644 --- a/quantui/freq_ir_workers.py +++ b/quantui/freq_ir_workers.py @@ -5,16 +5,19 @@ +Δ and −Δ). The default path in :mod:`quantui.freq_calc` runs them serially with each SCF internally parallelized via BLAS + libcint OpenMP. -When the user opts in via ``QUANTUI_FREQ_PARALLEL=1`` AND no GPU is -available AND the host has ``>= 4`` cores AND the molecule has -``>= 2`` atoms (i.e. ``>= 6`` displacements), the freq_calc driver hands -this loop off to a ``ProcessPoolExecutor`` whose workers each call -:func:`run_displaced_scf` on one displaced geometry. Each worker process -re-imports PySCF, rebuilds the ``gto.Mole`` from the same atom string / -basis / charge / spin as the parent, applies the displacement, and runs -the SCF. The initial guess ``dm0`` is shared once per worker via a temp -pickle file (the path is passed through ``initargs``) so we don't pay -per-task IPC for a 100×100 matrix. +When the user opts in via ``QUANTUI_FREQ_PARALLEL=1`` AND the host has +``>= 4`` cores AND the molecule has ``>= 2`` atoms (i.e. ``>= 6`` +displacements), the freq_calc driver hands this loop off to a +``ProcessPoolExecutor`` whose workers each call :func:`run_displaced_scf` +on one displaced geometry. Workers are **CPU-only** (no gpu4pyscf) even +when the parent run used the GPU for the reference SCF and Hessian — on +HPC nodes with one GPU and many cores, parallel CPU displacements often +beat serial GPU ones. Each worker process re-imports PySCF, rebuilds the +``gto.Mole`` from the same atom string / basis / charge / spin as the +parent, applies the displacement, and runs the SCF. The initial guess +``dm0`` is shared once per worker via a temp pickle file (the path is +passed through ``initargs``) so we don't pay per-task IPC for a 100×100 +matrix. The functions in this module are intentionally top-level (not nested in ``freq_calc.py``) because ``ProcessPoolExecutor`` requires picklable @@ -168,7 +171,6 @@ def run_displaced_scf(coords_bohr_flat) -> Any: def parallel_enabled_for_run( cpu_count: int, displacement_count: int, - gpu_available: bool, ) -> bool: """Decide whether the freq_calc IR loop should use the parallel path. @@ -176,21 +178,19 @@ def parallel_enabled_for_run( consult the same predicate. The current rules: - **Opt-in**: ``QUANTUI_FREQ_PARALLEL`` env var must be truthy - (``"1"`` / ``"true"`` / ``"True"``). Shipping this off-by-default - while the parallel path matures. - - **No GPU**: if gpu4pyscf is doing the offload, each SCF is already - ~10× faster; running multiple in parallel would compete for one - GPU's VRAM and is not worth the complexity. Stay serial. + (``"1"`` / ``"true"`` / ``"True"``). Off by default while the + parallel path matures. - **Cores threshold**: at least 4 cores. Below that, the BLAS oversubscription tradeoff doesn't pay off. - **Displacement threshold**: at least 6 (i.e. ``>= 2`` atoms). For a diatomic the serial loop is 12 SCFs at most and parallel overhead dominates. + + When this returns ``True``, displaced SCFs run on CPU worker processes + regardless of whether gpu4pyscf accelerated the reference SCF/Hessian. """ if not _truthy(os.environ.get("QUANTUI_FREQ_PARALLEL", "")): return False - if gpu_available: - return False if cpu_count < 4: return False if displacement_count < 6: diff --git a/quantui/help_content.py b/quantui/help_content.py index cd33f5f..2189154 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -70,7 +70,11 @@ " Moderate" "Frequency" " Vibrational modes + IR spectrum; " - "confirms a true minimum (no imaginary modes)" + "confirms a true minimum (no imaginary modes). On HPC clusters with " + "many CPU cores, admins can set " + "QUANTUI_FREQ_PARALLEL=1 to parallelize the IR " + "finite-difference step on CPU while the main SCF still uses the " + "GPU when available." " Higher" "UV-Vis (TD-DFT)" " Electronic excitations / absorption " diff --git a/tests/test_est_frequency_cost_model.py b/tests/test_est_frequency_cost_model.py index 5872977..e13f12b 100644 --- a/tests/test_est_frequency_cost_model.py +++ b/tests/test_est_frequency_cost_model.py @@ -305,10 +305,9 @@ def test_parallel_reduces_estimate_when_env_var_on_and_gates_pass( # the floor is 3× scf — which is well above zero/negative. assert cost_parallel["seconds"] > cost_serial["seconds"] * 0.1 - def test_gpu_run_stays_serial_even_with_env_var( + def test_gpu_run_uses_parallel_estimate_when_env_var_on( self, isolated_perf_log, monkeypatch ): - # parallel_enabled_for_run gates off when gpu_available=True. monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") for _ in range(5): _seed_sp_record( @@ -321,34 +320,28 @@ def test_gpu_run_stays_serial_even_with_env_var( n_basis=120, gpu_used=True, ) - cost = _estimate_frequency_cost( + cost_parallel = _estimate_frequency_cost( n_atoms=12, n_electrons=42, method="B3LYP", basis="6-31G*", n_basis=120, n_cores=8, - gpu_used=True, # ← GPU run — parallel must NOT engage + gpu_used=True, ) - assert cost is not None - sp = estimate_time( + monkeypatch.delenv("QUANTUI_FREQ_PARALLEL") + cost_serial = _estimate_frequency_cost( n_atoms=12, n_electrons=42, method="B3LYP", basis="6-31G*", n_basis=120, n_cores=8, - calc_type="single_point", gpu_used=True, ) - assert sp is not None - # Serial expectation despite env var. - expected = ( - sp["seconds"] - + _HESSIAN_MULTIPLIER_HF_DFT * sp["seconds"] - + 6 * 12 * sp["seconds"] - ) - assert cost["seconds"] == pytest.approx(expected, rel=1e-6) + assert cost_parallel is not None + assert cost_serial is not None + assert cost_parallel["seconds"] < cost_serial["seconds"] class TestEstimateTimeIntegration: diff --git a/tests/test_freq_ir_workers.py b/tests/test_freq_ir_workers.py index cd60004..bf71e43 100644 --- a/tests/test_freq_ir_workers.py +++ b/tests/test_freq_ir_workers.py @@ -4,7 +4,7 @@ PySCF-gated ``test_freq_calc.py::TestIRIntensities`` path and runs on WSL. These tests pin the contracts that don't require PySCF: -- ``parallel_enabled_for_run`` gate logic (env-var opt-in, GPU veto, core +- ``parallel_enabled_for_run`` gate logic (env-var opt-in, core threshold, displacement threshold). - ``pick_worker_count`` heuristic. - ``threads_per_worker`` BLAS budgeting math. @@ -30,7 +30,7 @@ def test_off_by_default_when_env_unset(self, monkeypatch): monkeypatch.delenv("QUANTUI_FREQ_PARALLEL", raising=False) assert ( parallel_enabled_for_run( - cpu_count=16, displacement_count=60, gpu_available=False + cpu_count=16, displacement_count=60 ) is False ) @@ -39,7 +39,7 @@ def test_off_when_env_falsy(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "0") assert ( parallel_enabled_for_run( - cpu_count=16, displacement_count=60, gpu_available=False + cpu_count=16, displacement_count=60 ) is False ) @@ -48,7 +48,7 @@ def test_on_when_env_truthy_and_conditions_met(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") assert ( parallel_enabled_for_run( - cpu_count=8, displacement_count=18, gpu_available=False + cpu_count=8, displacement_count=18 ) is True ) @@ -57,20 +57,19 @@ def test_env_truthy_string_variants_accepted(self, monkeypatch): for val in ("1", "true", "True", "yes", "on"): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", val) assert parallel_enabled_for_run( - cpu_count=8, displacement_count=18, gpu_available=False + cpu_count=8, displacement_count=18 ), f"value {val!r} should be truthy" - def test_gpu_available_vetoes_parallel(self, monkeypatch): - # Even with the env opt-in + enough cores + enough displacements, - # an available GPU keeps the loop serial (one SCF at a time, each - # on GPU). Multiple workers sharing one GPU is not worth the - # complexity for v1. + def test_gpu_available_does_not_veto_parallel(self, monkeypatch): + # NCShare-style nodes: one GPU, many CPU cores. Opt-in parallel + # uses CPU workers for displacements while the reference SCF/Hessian + # may still have used gpu4pyscf. monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") assert ( parallel_enabled_for_run( - cpu_count=16, displacement_count=60, gpu_available=True + cpu_count=16, displacement_count=60 ) - is False + is True ) def test_too_few_cores_vetoes_parallel(self, monkeypatch): @@ -78,7 +77,7 @@ def test_too_few_cores_vetoes_parallel(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") assert ( parallel_enabled_for_run( - cpu_count=2, displacement_count=60, gpu_available=False + cpu_count=2, displacement_count=60 ) is False ) @@ -91,14 +90,14 @@ def test_too_few_displacements_vetoes_parallel(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") assert ( parallel_enabled_for_run( - cpu_count=16, displacement_count=4, gpu_available=False + cpu_count=16, displacement_count=4 ) is False ) # 6 displacements is exactly at the threshold and should pass. assert ( parallel_enabled_for_run( - cpu_count=16, displacement_count=6, gpu_available=False + cpu_count=16, displacement_count=6 ) is True ) From db895d772871bf3d7cbf03fcf1d60be8a3787386 Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sun, 30 Aug 2026 15:08:13 +0000 Subject: [PATCH 2/3] Add parallel IR FD toggle to System Settings tab Persist compute.freq_parallel in user settings with a checkbox on the Status/Settings panel. freq_ir_workers reads settings when the env var is unset; QUANTUI_FREQ_PARALLEL still overrides for HPC job scripts. Co-authored-by: Jonathan Schultz --- .github/copilot-instructions.md | 2 +- docs/CLI.md | 2 +- quantui/app.py | 26 +++++++++++++++++++++ quantui/app_builders.py | 17 ++++++++++++++ quantui/freq_ir_workers.py | 31 +++++++++++++++++++++----- quantui/help_content.py | 31 +++++++++++++++++++++----- quantui/user_settings.py | 15 +++++++++++++ tests/test_est_frequency_cost_model.py | 2 +- tests/test_freq_ir_workers.py | 27 ++++++++++++++++++++++ tests/test_user_settings.py | 16 +++++++++++++ 10 files changed, 155 insertions(+), 14 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0ea95e0..3d2b7ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -678,7 +678,7 @@ Install all runtime + dev extras: `pip install -e ".[pyscf,ase,app,notebook,dev] | --- | --- | --- | | `QUANTUI_RESULTS_DIR` | `./results` | Where calculation results are saved | | `QUANTUI_LOG_DIR` | `~/.quantui/logs` | Where perf_log and event_log live | -| `QUANTUI_FREQ_PARALLEL` | (unset / off) | When truthy (`1`, `true`, `yes`, `on`), fan out IR-intensity finite-difference SCFs across CPU worker processes in `freq_calc.py` (via `freq_ir_workers.py`). Reference SCF + Hessian still use gpu4pyscf when available; workers are CPU-only. Requires ≥4 cores and ≥2 atoms. Useful on NCShare-style nodes (one GPU, many cores). | +| `QUANTUI_FREQ_PARALLEL` | (unset / off) | When truthy (`1`, `true`, `yes`, `on`), fan out IR-intensity finite-difference SCFs across CPU worker processes. Same as the System Settings checkbox; **overrides** the saved setting when set. Reference SCF + Hessian still use gpu4pyscf when available. Requires ≥4 cores and ≥2 atoms. | | `QUANTUI_SETTINGS_PATH` | `~/.quantui/settings.json` | User-preferences file (`user_settings.py`); override for tests | --- diff --git a/docs/CLI.md b/docs/CLI.md index 2ab5aa7..8976689 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -251,7 +251,7 @@ successfully; only the auto-open is best-effort. | --- | --- | | `QUANTUI_LOG_DIR` | Override the default `~/.quantui/logs/` location. The dashboard's default output (`~/.quantui/dashboard.html`) follows: it lives one level up from the active `QUANTUI_LOG_DIR`. | | `QUANTUI_DISABLE_GPU` | Force CPU mode even when gpu4pyscf is installed. `quantui gpu check` reports this as the reason. Accepted truthy values: `1`, `true`, `True`. | -| `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Reference SCF and Hessian still use gpu4pyscf when available; only the displacement loop fans out to processes. Useful on HPC nodes with one GPU and many cores (e.g. NCShare). Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. | +| `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Same effect as the **Parallelize IR intensity displacements** checkbox on the System Settings tab; when this env var is set it overrides the saved setting. Reference SCF and Hessian still use gpu4pyscf when available. Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. | --- diff --git a/quantui/app.py b/quantui/app.py index cc3b772..0fb797f 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1306,6 +1306,7 @@ class QuantUIApp: _files_up_btn: Any gpu_enabled_cb: Any density_fit_enabled_cb: Any + freq_parallel_enabled_cb: Any execution_backend_dd: Any help_content_html: Any help_tab_panel: Any @@ -1963,6 +1964,7 @@ def _build_status_panel(self) -> None: vib_framerate_fps=self._user_settings.viz.vib_framerate_fps, gpu_enabled=self._user_settings.compute.gpu_enabled, density_fit_enabled=self._user_settings.compute.density_fit, + freq_parallel_enabled=self._user_settings.compute.freq_parallel, execution_backend=self._user_settings.compute.execution_backend, slurm_available=is_slurm_available(), ) @@ -2372,6 +2374,9 @@ def _wire_callbacks(self) -> None: self.density_fit_enabled_cb.observe( self._safe_cb(self._on_density_fit_enabled_changed), names="value" ) + self.freq_parallel_enabled_cb.observe( + self._safe_cb(self._on_freq_parallel_enabled_changed), names="value" + ) self.execution_backend_dd.observe( self._safe_cb(self._on_execution_backend_changed), names="value" ) @@ -3736,6 +3741,27 @@ def _on_density_fit_enabled_changed(self, change) -> None: except OSError: pass + def _on_freq_parallel_enabled_changed(self, change) -> None: + """Persist the parallel IR finite-difference preference. + + The freq_calc driver reads this via :func:`freq_ir_workers._freq_parallel_opt_in` + on each run (unless ``QUANTUI_FREQ_PARALLEL`` overrides in the environment). + Refresh the time estimate because parallel divides the IR term in the model. + """ + new_val = bool(change["new"]) + if new_val == self._user_settings.compute.freq_parallel: + return + self._user_settings.compute.freq_parallel = new_val + self._user_settings.save() + try: + self._update_estimate() + except Exception: # noqa: BLE001 — best-effort estimate refresh + pass + try: + _calc_log.log_event("freq_parallel_enabled_changed", f"freq_parallel={new_val}") + except OSError: + pass + def _on_execution_backend_changed(self, change) -> None: """Persist local vs SLURM batch execution preference.""" new_val = str(change["new"]) diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 47ad96c..5d9a868 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -121,6 +121,7 @@ def build_status_panel( vib_framerate_fps: int = 10, gpu_enabled: bool = True, density_fit_enabled: bool = False, + freq_parallel_enabled: bool = False, execution_backend: str = "local", slurm_available: bool = False, ) -> None: @@ -314,6 +315,20 @@ def _render_status(gpu_state: Any) -> str: layout=layout_fn(width="320px", margin="2px 0 0 0"), ) + fp_toggle_label = widgets.HTML( + f'
Parallel IR finite differences ' + f'' + "(persists across launches; CPU workers for IR intensities on " + "multi-core hosts — see Help → Parallel IR finite differences)
" + ) + app.freq_parallel_enabled_cb = widgets.Checkbox( + value=freq_parallel_enabled, + description="Parallelize IR intensity displacements (CPU)", + indent=False, + layout=layout_fn(width="320px", margin="2px 0 0 0"), + ) + exec_backend_label = widgets.HTML( f'
Execution backend ' @@ -354,6 +369,8 @@ def _render_status(gpu_state: Any) -> str: app.gpu_enabled_cb, df_toggle_label, app.density_fit_enabled_cb, + fp_toggle_label, + app.freq_parallel_enabled_cb, exec_backend_label, app.execution_backend_dd, exec_backend_note, diff --git a/quantui/freq_ir_workers.py b/quantui/freq_ir_workers.py index 37af221..9e29fcf 100644 --- a/quantui/freq_ir_workers.py +++ b/quantui/freq_ir_workers.py @@ -5,8 +5,9 @@ +Δ and −Δ). The default path in :mod:`quantui.freq_calc` runs them serially with each SCF internally parallelized via BLAS + libcint OpenMP. -When the user opts in via ``QUANTUI_FREQ_PARALLEL=1`` AND the host has -``>= 4`` cores AND the molecule has ``>= 2`` atoms (i.e. ``>= 6`` +When the user opts in via the System Settings checkbox (persisted as +``compute.freq_parallel``) or ``QUANTUI_FREQ_PARALLEL=1`` (env overrides +settings when set) AND the host has ``>= 4`` cores AND the molecule has ``>= 2`` atoms (i.e. ``>= 6`` displacements), the freq_calc driver hands this loop off to a ``ProcessPoolExecutor`` whose workers each call :func:`run_displaced_scf` on one displaced geometry. Workers are **CPU-only** (no gpu4pyscf) even @@ -168,6 +169,24 @@ def run_displaced_scf(coords_bohr_flat) -> Any: return np.array(mf.dip_moment(verbose=0)) +def _freq_parallel_opt_in() -> bool: + """Whether parallel IR displacements are opted in. + + Precedence: + 1. ``QUANTUI_FREQ_PARALLEL`` env var when set (HPC job-script override). + 2. ``compute.freq_parallel`` in :mod:`quantui.user_settings` (Settings tab). + """ + env_val = os.environ.get("QUANTUI_FREQ_PARALLEL") + if env_val is not None: + return _truthy(env_val) + try: + from quantui.user_settings import UserSettings + + return bool(UserSettings.load().compute.freq_parallel) + except Exception: # noqa: BLE001 — settings must never gate a calc + return False + + def parallel_enabled_for_run( cpu_count: int, displacement_count: int, @@ -177,9 +196,9 @@ def parallel_enabled_for_run( Centralised in this module so both the driver and the tests can consult the same predicate. The current rules: - - **Opt-in**: ``QUANTUI_FREQ_PARALLEL`` env var must be truthy - (``"1"`` / ``"true"`` / ``"True"``). Off by default while the - parallel path matures. + - **Opt-in**: :func:`_freq_parallel_opt_in` must be true — from the + Settings tab checkbox (persisted) or ``QUANTUI_FREQ_PARALLEL=1`` in + the environment (overrides settings when set). Off by default. - **Cores threshold**: at least 4 cores. Below that, the BLAS oversubscription tradeoff doesn't pay off. - **Displacement threshold**: at least 6 (i.e. ``>= 2`` atoms). For a @@ -189,7 +208,7 @@ def parallel_enabled_for_run( When this returns ``True``, displaced SCFs run on CPU worker processes regardless of whether gpu4pyscf accelerated the reference SCF/Hessian. """ - if not _truthy(os.environ.get("QUANTUI_FREQ_PARALLEL", "")): + if not _freq_parallel_opt_in(): return False if cpu_count < 4: return False diff --git a/quantui/help_content.py b/quantui/help_content.py index 2189154..d93e454 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -70,11 +70,10 @@ " Moderate" "Frequency" " Vibrational modes + IR spectrum; " - "confirms a true minimum (no imaginary modes). On HPC clusters with " - "many CPU cores, admins can set " - "QUANTUI_FREQ_PARALLEL=1 to parallelize the IR " - "finite-difference step on CPU while the main SCF still uses the " - "GPU when available." + "confirms a true minimum (no imaginary modes). On multi-core hosts " + "(e.g. NCShare), enable Parallel IR finite differences on the " + "System Settings tab to speed the IR-intensity step with CPU workers " + "while the main SCF still uses the GPU when available." " Higher" "UV-Vis (TD-DFT)" " Electronic excitations / absorption " @@ -259,6 +258,28 @@ "unsure, leaving it off is the safe, exact choice.

" ), }, + "freq_parallel": { + "title": "Parallel IR finite differences — when to enable", + "body": ( + "

After the Hessian is built, QuantUI runs many small SCF " + "calculations at slightly displaced geometries to compute " + "IR intensities (how strongly each vibration absorbs " + "infrared light). By default these run one at a time.

" + "

On a multi-core machine — especially HPC nodes with " + "one GPU and dozens or hundreds of CPU cores — you can enable " + "Parallelize IR intensity displacements (CPU) on the " + "System Settings tab. QuantUI then spreads those " + "displacements across CPU worker processes while the reference " + "SCF and Hessian still use the GPU when available.

" + "

Requirements: at least 4 CPU cores and a molecule " + "with 2 or more atoms. Off by default — try it when a frequency " + "run spends a long time on the IR-intensity step.

" + "

Cluster jobs: you can also set " + "QUANTUI_FREQ_PARALLEL=1 in the environment before " + "launching Voilà; that overrides the Settings checkbox for that " + "session.

" + ), + }, "homo_lumo": { "title": "What is the HOMO-LUMO gap?", "body": ( diff --git a/quantui/user_settings.py b/quantui/user_settings.py index 46bc32b..55311b5 100644 --- a/quantui/user_settings.py +++ b/quantui/user_settings.py @@ -113,6 +113,11 @@ class ComputeSettings: # only PyFock selects PyFock. UI wiring lands in PYF.4 — PYF.1 persists only. quantum_engine: str = "auto" + # Whether the IR-intensity finite-difference loop may fan displaced SCFs + # out to parallel CPU workers (see quantui.freq_ir_workers). Default off. + # ``QUANTUI_FREQ_PARALLEL`` in the environment overrides this when set. + freq_parallel: bool = False + @dataclass class UserSettings: @@ -290,6 +295,16 @@ def _from_dict(cls, data: object) -> UserSettings: candidate_engine, compute.quantum_engine, ) + if "freq_parallel" in compute_section: + candidate_fp = compute_section["freq_parallel"] + if isinstance(candidate_fp, bool): + compute.freq_parallel = candidate_fp + else: + _LOG.warning( + "Invalid compute.freq_parallel %r; using %r", + candidate_fp, + compute.freq_parallel, + ) return cls(viz=viz, theme=theme, compute=compute) diff --git a/tests/test_est_frequency_cost_model.py b/tests/test_est_frequency_cost_model.py index e13f12b..ed0b54f 100644 --- a/tests/test_est_frequency_cost_model.py +++ b/tests/test_est_frequency_cost_model.py @@ -281,7 +281,7 @@ def test_parallel_reduces_estimate_when_env_var_on_and_gates_pass( basis="6-31G*", n_basis=120, n_cores=8, - gpu_used=False, # parallel gated off on GPU + gpu_used=False, # parallel gated by opt-in, not GPU ) # Compare to serial (same params, different env var). monkeypatch.delenv("QUANTUI_FREQ_PARALLEL") diff --git a/tests/test_freq_ir_workers.py b/tests/test_freq_ir_workers.py index bf71e43..197d059 100644 --- a/tests/test_freq_ir_workers.py +++ b/tests/test_freq_ir_workers.py @@ -21,6 +21,7 @@ pick_worker_count, threads_per_worker, ) +from quantui.user_settings import UserSettings class TestParallelEnabledGate: @@ -102,6 +103,32 @@ def test_too_few_displacements_vetoes_parallel(self, monkeypatch): is True ) + def test_settings_checkbox_opt_in_without_env(self, monkeypatch, tmp_path): + monkeypatch.delenv("QUANTUI_FREQ_PARALLEL", raising=False) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + settings = UserSettings() + settings.compute.freq_parallel = True + settings.save() + assert ( + parallel_enabled_for_run( + cpu_count=8, displacement_count=18 + ) + is True + ) + + def test_env_var_overrides_settings_off(self, monkeypatch, tmp_path): + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) + settings = UserSettings() + settings.compute.freq_parallel = True + settings.save() + monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "0") + assert ( + parallel_enabled_for_run( + cpu_count=16, displacement_count=60 + ) + is False + ) + class TestPickWorkerCount: """Worker count = ``min(cpu // 2, displacement_count)``, floored at 1.""" diff --git a/tests/test_user_settings.py b/tests/test_user_settings.py index a96cb6c..0d66d1b 100644 --- a/tests/test_user_settings.py +++ b/tests/test_user_settings.py @@ -39,6 +39,9 @@ def test_to_dict_uses_current_schema_version(self): def test_default_vib_framerate_is_10(self): assert UserSettings().viz.vib_framerate_fps == 10 + def test_default_freq_parallel_is_off(self): + assert UserSettings().compute.freq_parallel is False + class TestLoad: def test_missing_file_returns_defaults(self, tmp_path): @@ -164,6 +167,19 @@ def test_unknown_fields_are_tolerated(self, tmp_path): settings = UserSettings.load(path) assert settings.viz.default_backend == "py3dmol" + def test_freq_parallel_round_trips(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "_schema_version": 1, + "compute": {"freq_parallel": True}, + } + ) + ) + settings = UserSettings.load(path) + assert settings.compute.freq_parallel is True + class TestSave: def test_creates_file(self, tmp_path): From dd253a60bce5d72a11e85892d188c18ebb5574da Mon Sep 17 00:00:00 2001 From: NCCU Schultz Lab Date: Sun, 30 Aug 2026 15:10:10 +0000 Subject: [PATCH 3/3] Apply black formatting for CI lint check Reformat test_freq_ir_workers.py and app.py to satisfy pre-commit black. Co-authored-by: Jonathan Schultz --- quantui/app.py | 4 ++- tests/test_freq_ir_workers.py | 63 +++++------------------------------ 2 files changed, 12 insertions(+), 55 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index 0fb797f..878a9be 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -3758,7 +3758,9 @@ def _on_freq_parallel_enabled_changed(self, change) -> None: except Exception: # noqa: BLE001 — best-effort estimate refresh pass try: - _calc_log.log_event("freq_parallel_enabled_changed", f"freq_parallel={new_val}") + _calc_log.log_event( + "freq_parallel_enabled_changed", f"freq_parallel={new_val}" + ) except OSError: pass diff --git a/tests/test_freq_ir_workers.py b/tests/test_freq_ir_workers.py index 197d059..29d5326 100644 --- a/tests/test_freq_ir_workers.py +++ b/tests/test_freq_ir_workers.py @@ -29,30 +29,15 @@ class TestParallelEnabledGate: def test_off_by_default_when_env_unset(self, monkeypatch): monkeypatch.delenv("QUANTUI_FREQ_PARALLEL", raising=False) - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=60 - ) - is False - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=60) is False def test_off_when_env_falsy(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "0") - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=60 - ) - is False - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=60) is False def test_on_when_env_truthy_and_conditions_met(self, monkeypatch): monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") - assert ( - parallel_enabled_for_run( - cpu_count=8, displacement_count=18 - ) - is True - ) + assert parallel_enabled_for_run(cpu_count=8, displacement_count=18) is True def test_env_truthy_string_variants_accepted(self, monkeypatch): for val in ("1", "true", "True", "yes", "on"): @@ -66,22 +51,12 @@ def test_gpu_available_does_not_veto_parallel(self, monkeypatch): # uses CPU workers for displacements while the reference SCF/Hessian # may still have used gpu4pyscf. monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=60 - ) - is True - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=60) is True def test_too_few_cores_vetoes_parallel(self, monkeypatch): # Below 4 cores the BLAS-oversubscription tradeoff doesn't pay off. monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") - assert ( - parallel_enabled_for_run( - cpu_count=2, displacement_count=60 - ) - is False - ) + assert parallel_enabled_for_run(cpu_count=2, displacement_count=60) is False def test_too_few_displacements_vetoes_parallel(self, monkeypatch): # For a diatomic (2 atoms → 12 displacements? No, 2*3*2=12) we still @@ -89,19 +64,9 @@ def test_too_few_displacements_vetoes_parallel(self, monkeypatch): # For a hypothetical 5-displacement case (not real, but the gate is # generic) we'd skip parallel. monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "1") - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=4 - ) - is False - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=4) is False # 6 displacements is exactly at the threshold and should pass. - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=6 - ) - is True - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=6) is True def test_settings_checkbox_opt_in_without_env(self, monkeypatch, tmp_path): monkeypatch.delenv("QUANTUI_FREQ_PARALLEL", raising=False) @@ -109,12 +74,7 @@ def test_settings_checkbox_opt_in_without_env(self, monkeypatch, tmp_path): settings = UserSettings() settings.compute.freq_parallel = True settings.save() - assert ( - parallel_enabled_for_run( - cpu_count=8, displacement_count=18 - ) - is True - ) + assert parallel_enabled_for_run(cpu_count=8, displacement_count=18) is True def test_env_var_overrides_settings_off(self, monkeypatch, tmp_path): monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json")) @@ -122,12 +82,7 @@ def test_env_var_overrides_settings_off(self, monkeypatch, tmp_path): settings.compute.freq_parallel = True settings.save() monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "0") - assert ( - parallel_enabled_for_run( - cpu_count=16, displacement_count=60 - ) - is False - ) + assert parallel_enabled_for_run(cpu_count=16, displacement_count=60) is False class TestPickWorkerCount: