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 @@
"
| 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):
|