Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down
1 change: 1 addition & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

---

Expand Down
28 changes: 28 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"])
Expand Down
17 changes: 17 additions & 0 deletions quantui/app_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'<div style="font-size:12px;color:{_theme.css.TEXT_SLATE_DARK};margin-top:12px;'
'margin-bottom:0px">Parallel IR finite differences '
f'<span style="color:{_theme.css.TEXT_SUBTLE};font-size:11px">'
"(persists across launches; CPU workers for IR intensities on "
"multi-core hosts — see Help → Parallel IR finite differences)</span></div>"
)
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'<div style="font-size:12px;color:{_theme.css.TEXT_SLATE_DARK};margin-top:12px;'
'margin-bottom:0px">Execution backend '
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions quantui/calc_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions quantui/freq_calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 39 additions & 20 deletions quantui/freq_ir_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion quantui/help_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@
" <td style='padding:3px 12px;'>Moderate</td></tr>"
"<tr><td style='padding:3px 12px;'><b>Frequency</b></td>"
" <td style='padding:3px 12px;'>Vibrational modes + IR spectrum; "
"confirms a true minimum (no imaginary modes)</td>"
"confirms a true minimum (no imaginary modes). On multi-core hosts "
"(e.g. NCShare), enable <b>Parallel IR finite differences</b> on the "
"System Settings tab to speed the IR-intensity step with CPU workers "
"while the main SCF still uses the GPU when available.</td>"
" <td style='padding:3px 12px;'>Higher</td></tr>"
"<tr><td style='padding:3px 12px;'><b>UV-Vis (TD-DFT)</b></td>"
" <td style='padding:3px 12px;'>Electronic excitations / absorption "
Expand Down Expand Up @@ -293,6 +296,28 @@
"unsure, leaving it off is the safe, exact choice.</p>"
),
},
"freq_parallel": {
"title": "Parallel IR finite differences — when to enable",
"body": (
"<p>After the Hessian is built, QuantUI runs many small SCF "
"calculations at slightly displaced geometries to compute "
"<b>IR intensities</b> (how strongly each vibration absorbs "
"infrared light). By default these run one at a time.</p>"
"<p>On a <b>multi-core</b> machine — especially HPC nodes with "
"one GPU and dozens or hundreds of CPU cores — you can enable "
"<b>Parallelize IR intensity displacements (CPU)</b> on the "
"<b>System Settings</b> tab. QuantUI then spreads those "
"displacements across CPU worker processes while the reference "
"SCF and Hessian still use the GPU when available.</p>"
"<p><b>Requirements:</b> 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.</p>"
"<p><b>Cluster jobs:</b> you can also set "
"<code>QUANTUI_FREQ_PARALLEL=1</code> in the environment before "
"launching Voilà; that overrides the Settings checkbox for that "
"session.</p>"
),
},
"homo_lumo": {
"title": "What is the HOMO-LUMO gap?",
"body": (
Expand Down
15 changes: 15 additions & 0 deletions quantui/user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
25 changes: 9 additions & 16 deletions tests/test_est_frequency_cost_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(
Expand All @@ -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:
Expand Down
Loading