diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 65764a6..3d2b7ea 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. 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 74a7018..8976689 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). 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 4683a21..af0d22e 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1305,6 +1305,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 @@ -1962,6 +1963,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(), ) @@ -2371,6 +2373,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" ) @@ -3735,6 +3740,29 @@ 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 0ca2d22..bc0ba84 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/calc_log.py b/quantui/calc_log.py index 13b850e..a40b305 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 5a59140..a212802 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..9e29fcf 100644 --- a/quantui/freq_ir_workers.py +++ b/quantui/freq_ir_workers.py @@ -5,16 +5,20 @@ +Δ 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 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 +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 @@ -165,31 +169,46 @@ 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, - gpu_available: bool, ) -> bool: """Decide whether the freq_calc IR loop should use the parallel path. 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"``). 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. + - **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 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: + 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 f0aa26f..074ea3a 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -108,7 +108,10 @@ " Moderate" "Frequency" " Vibrational modes + IR spectrum; " - "confirms a true minimum (no imaginary modes)" + "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 " @@ -293,6 +296,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 09d7cdc..cc57cab 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 5872977..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") @@ -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..29d5326 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. @@ -21,6 +21,7 @@ pick_worker_count, threads_per_worker, ) +from quantui.user_settings import UserSettings class TestParallelEnabledGate: @@ -28,60 +29,34 @@ 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, gpu_available=False - ) - 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, gpu_available=False - ) - 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, gpu_available=False - ) - 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"): 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 - ) - is False - ) + 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, gpu_available=False - ) - 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,25 @@ 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, gpu_available=False - ) - 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, gpu_available=False - ) - 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) + 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: 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):