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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ selected when PySCF is absent). The validated subset supports neutral,
closed-shell PBE single points and geometry optimizations with def2-SVP or
def2-TZVP. Density fitting is always enabled, analytical gradients drive
optimization, and orbital, Mulliken, dipole, and cube analysis are retained.
Hybrids, charged/open-shell systems, solvent, checkpoint warm starts, and GPU
remain PySCF-only.
Hybrids, charged/open-shell systems, solvent, and checkpoint warm starts remain
PySCF-only. Optional PyFock GPU acceleration is available separately through
CuPy; geometry optimization uses numerical forces on GPU because PyFock 0.1.7
does not yet provide analytical GPU gradients.

PySCF does not install on Windows natively. For the complete feature set, the
[`apptainer/quantui.def`](https://github.com/The-Schultz-Lab/QuantUI/blob/main/apptainer/quantui.def) container bundles
Expand Down Expand Up @@ -240,6 +242,27 @@ and result cards will display the compute device.
Whenever gpu4pyscf can't offload a particular call, QuantUI falls back
to CPU automatically and the result card reflects which device ran.

### Optional: PyFock GPU acceleration

PyFock uses its own CuPy/Numba CUDA implementation; it does not use
`gpu4pyscf`. Install the PyFock engine and the CUDA-suffixed CuPy extra that
matches the NVIDIA driver reported by `nvidia-smi`:

```bash
# CUDA 13.x driver
pip install "quantui[pyfock,pyfock-gpu-cuda13x]"

# CUDA 12.x driver
pip install "quantui[pyfock,pyfock-gpu-cuda12x]"
```

PyFock GPU use is guarded independently: QuantUI requires CuPy to import and
report a CUDA device, and still honors the Settings GPU toggle and
`QUANTUI_DISABLE_GPU=1`. If the probe fails, PyFock falls back to CPU and
reports the reason. GPU single points use PyFock's GPU SCF/integral/XC path;
GPU geometry optimizations use numerical finite-difference forces because the
installed PyFock release's analytical gradient implementation is CPU-only.

### Optional: GFN-FF metal pre-optimization (xtb)

The classical (MMFF/UFF) pre-optimizer relies on RDKit's organic valence
Expand Down
6 changes: 5 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ quantui log tail -n 200 | grep -i error | tail -5
Probe whether QuantUI's GPU offload path is functional in the current
environment. This is the canonical one-liner for verifying that
`gpu4pyscf` + `cupy` are installed correctly and that
`is_gpu_available()` will return `True` when the app runs.
`is_gpu_available()` will return `True` when the PySCF app path runs. Use
`--engine pyfock` to probe PyFock's independent CuPy path.

### Flags

Expand All @@ -189,6 +190,9 @@ None.
# Is GPU offload working right now?
quantui gpu check

# Check the separate PyFock/CuPy path
quantui gpu check --engine pyfock

# Use in a shell condition
if quantui gpu check; then
echo "GPU mode"
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ gpu-cuda13x = [
"cutensor-cu13",
]

# PyFock GPU acceleration is a separate CuPy-only path. Do not install or
# probe gpu4pyscf for a PyFock calculation: the two engines have independent
# CUDA implementations and capability gates.
pyfock-gpu-cuda12x = [
"cupy-cuda12x",
]
pyfock-gpu-cuda13x = [
"cupy-cuda13x",
]

# Notebook smoke-test dependencies
notebook = [
"nbmake>=1.4.0",
Expand Down Expand Up @@ -329,6 +339,8 @@ markers = [
"network: marks tests that require network connectivity",
"notebook: marks notebook smoke tests",
"pyfock: marks real PyFock integration/parity tests (opt in with QUANTUI_RUN_PYFOCK_INTEGRATION=1)",
"gpu_integration: marks real NVIDIA GPU integration tests (opt in with QUANTUI_RUN_GPU_INTEGRATION=1)",
"pyfock_gpu: marks real PyFock CuPy GPU tests (opt in with QUANTUI_RUN_PYFOCK_GPU_INTEGRATION=1)",
]

[tool.coverage.run]
Expand Down
13 changes: 11 additions & 2 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5812,7 +5812,11 @@ def _run_required_final_single_point(target_mol, reason: str):
"atoms": list(target_mol.atoms),
"coordinates": [list(c) for c in target_mol.coordinates],
},
options={"verbose": 4, "scf_rescue": True},
options={
"verbose": 4,
"scf_rescue": True,
"use_gpu": bool(self._user_settings.compute.gpu_enabled),
},
progress_stream=log, # type: ignore[arg-type]
solvent=_solvent,
),
Expand Down Expand Up @@ -6007,6 +6011,7 @@ def _run_required_final_single_point(target_mol, reason: str):
),
"resume": _resume,
"scf_rescue": True,
"use_gpu": bool(self._user_settings.compute.gpu_enabled),
},
progress_stream=log, # type: ignore[arg-type]
checkpoint=_ckpt,
Expand Down Expand Up @@ -6430,7 +6435,11 @@ def _run_required_final_single_point(target_mol, reason: str):
"atoms": list(calc_mol.atoms),
"coordinates": [list(c) for c in calc_mol.coordinates],
},
options={"verbose": 4, "scf_rescue": True},
options={
"verbose": 4,
"scf_rescue": True,
"use_gpu": bool(self._user_settings.compute.gpu_enabled),
},
progress_stream=log, # type: ignore[arg-type]
solvent=_solvent,
checkpoint=_ckpt,
Expand Down
2 changes: 2 additions & 0 deletions quantui/backends/worker_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def optimization_result_payload(result, *, trajectory_file: str) -> Dict[str, An
"method": result.method,
"basis": result.basis,
"formula": result.formula,
"gpu_used": bool(getattr(result, "gpu_used", False)),
"gpu_name": getattr(result, "gpu_name", None),
"trajectory_file": trajectory_file,
}

Expand Down
55 changes: 39 additions & 16 deletions quantui/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
* ``quantui log tail [-n N]`` — print the last N event-log entries
(default 20). Reads ``~/.quantui/logs/event_log.jsonl`` honoring the
``QUANTUI_LOG_DIR`` env override.
* ``quantui gpu check`` — run QuantUI's GPU-offload detection and print
``(available, device-name)``. Exit code 0 when GPU is usable, 1 when
not — handy for one-line CI / shell-script gating.
* ``quantui gpu check [--engine pyscf|pyfock]`` — run one engine's GPU
detection and print ``(available, device-name)``. Exit code 0 when GPU is
usable, 1 when not — handy for one-line CI / shell-script gating.
* ``quantui analytics build [-o PATH] [--open]`` — build a self-contained
HTML analytics dashboard from ``perf_log.jsonl``. Default output:
``~/.quantui/dashboard.html``. Pass ``--open`` to automatically open
Expand Down Expand Up @@ -106,19 +106,36 @@ def _cmd_gpu_check(args: argparse.Namespace) -> int:
Returns exit code 0 when GPU offload is available, 1 when it's not —
so ``if quantui gpu check; then ...; fi`` works in shell scripts.
"""
from quantui.gpu_offload import is_gpu_available, is_low_fp64_device, probe_gpu

# The detection probe is cached; clear so each CLI invocation is
# fresh (the user may have just installed gpu4pyscf and wants to
# confirm without restarting their shell).
# cache_clear is forwarded from _probe_gpu's lru_cache onto this function
# at definition time (gpu_offload.py); mypy can't see a monkey-patched
# attribute across the module boundary.
is_gpu_available.cache_clear() # type: ignore[attr-defined]
available, name, reason = probe_gpu()
engine = getattr(args, "engine", "pyscf")
if engine == "pyfock":
from quantui.pyfock_gpu import (
clear_pyfock_gpu_probe_cache,
probe_pyfock_gpu,
)

clear_pyfock_gpu_probe_cache()
available, name, reason = probe_pyfock_gpu()
label = "PyFock GPU"
low_fp64 = False
else:
from quantui.gpu_offload import (
is_gpu_available,
is_low_fp64_device,
probe_gpu,
)

# The detection probe is cached; clear so each CLI invocation is
# fresh (the user may have just installed gpu4pyscf and wants to
# confirm without restarting their shell).
# cache_clear is forwarded from _probe_gpu's lru_cache onto this
# function at definition time; mypy can't see that attribute.
is_gpu_available.cache_clear() # type: ignore[attr-defined]
available, name, reason = probe_gpu()
label = "GPU offload"
low_fp64 = is_low_fp64_device(name)
if available:
print(f"GPU offload available: {name}")
if is_low_fp64_device(name):
print(f"{label} available: {name}")
if low_fp64:
# Available is not the same as worth using: PySCF is FP64
# throughout, and consumer cards gate double precision to a small
# fraction of single. Say so here rather than let the user discover
Expand Down Expand Up @@ -391,7 +408,13 @@ def _build_parser() -> argparse.ArgumentParser:
gpu_sub = gpu_parser.add_subparsers(dest="gpu_command", required=True)
gpu_check = gpu_sub.add_parser(
"check",
help="Run QuantUI's GPU-offload detection probe.",
help="Run a GPU detection probe.",
)
gpu_check.add_argument(
"--engine",
choices=("pyscf", "pyfock"),
default="pyscf",
help="Engine-specific GPU path to probe (default: pyscf).",
)
gpu_check.set_defaults(func=_cmd_gpu_check)

Expand Down
7 changes: 6 additions & 1 deletion quantui/engines/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""
Quantum-engine contract types (v0.1).

See ``QuantUI-development-tracking/TODO/QUANTUM-ENGINE-CONTRACT.md``.
The engine contract is maintained alongside the project's development
documentation.
"""

from __future__ import annotations
Expand Down Expand Up @@ -85,6 +86,8 @@ class EngineResult:
homo_lumo_gap_ev: Optional[float] = None
warnings: List[str] = field(default_factory=list)
error: Optional[Dict[str, Any]] = None
gpu_used: bool = False
gpu_name: Optional[str] = None
native_result: Optional[Any] = field(default=None, repr=False, compare=False)

def to_dict(self) -> Dict[str, Any]:
Expand Down Expand Up @@ -118,6 +121,8 @@ def to_session_result(self) -> Any:
basis=self.basis,
formula=self.formula,
density_fit=self.engine_id == "pyfock",
gpu_used=self.gpu_used,
gpu_name=self.gpu_name,
scf_variant="RKS" if self.engine_id == "pyfock" else "",
engine_id=self.engine_id,
)
Expand Down
31 changes: 27 additions & 4 deletions quantui/engines/pyfock_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from contextlib import redirect_stderr, redirect_stdout
from typing import Any, Optional

from ..pyfock_gpu import resolve_pyfock_gpu
from .base import (
EngineCapabilities,
EngineRequest,
Expand Down Expand Up @@ -59,14 +60,15 @@ def capabilities(self) -> EngineCapabilities:
supported_basis_sets=_PYFOCK_BASES,
supports_solvent=False,
supports_checkpoint_warm_start=False,
supports_gpu=False,
supports_gpu=True,
supports_post_hf=False,
supports_orbital_export=True,
platform_notes=(
"Neutral, closed-shell PBE single points and geometry optimizations. "
"Density fitting and analytical gradients are used; hybrids, "
"solvent, checkpoints, and GPU are gated off. "
"Install with pip install quantui[pyfock]."
"solvent, and checkpoints are gated off. Optional PyFock GPU "
"acceleration uses CuPy; install with "
"pip install 'quantui[pyfock,pyfock-gpu-cuda12x]'."
),
recommended_auxbasis=_AUX_BASIS,
version=_pyfock_version(),
Expand All @@ -87,6 +89,9 @@ def _run_single_point(self, request: EngineRequest) -> EngineResult:
[symbol, float(x), float(y), float(z)]
for symbol, (x, y, z) in zip(atoms, coordinates)
]
_use_gpu, _gpu_name, _gpu_reason = resolve_pyfock_gpu(
request.options.get("use_gpu")
)

try:
with redirect_stdout(stream), redirect_stderr(stream):
Expand All @@ -105,6 +110,10 @@ def _run_single_point(self, request: EngineRequest) -> EngineResult:
f"Engine: PyFock {_pyfock_version() or 'unknown'} | "
f"{request.method}/{request.basis} | density fitting: on"
)
if _use_gpu:
print(f"GPU acceleration: active ({_gpu_name}) — CuPy/Numba path")
elif request.options.get("use_gpu") is not False and _gpu_reason:
print(f"GPU acceleration: unavailable — {_gpu_reason}")
mol = Mol(atoms=pyfock_atoms, charge=0)
basis = Basis(
mol,
Expand All @@ -123,7 +132,7 @@ def _run_single_point(self, request: EngineRequest) -> EngineResult:
request.options.get("conv_crit"), default=1.0e-7
),
ncores=_positive_int(request.options.get("ncores"), default=1),
use_gpu=False,
use_gpu=_use_gpu,
)
dft.max_itr = _positive_int(
request.options.get("max_iterations"), default=50
Expand Down Expand Up @@ -159,7 +168,16 @@ def _run_single_point(self, request: EngineRequest) -> EngineResult:
getattr(dft, "mo_energies", None),
getattr(dft, "mo_occupations", None),
)
_gpu_used = bool(getattr(dft, "use_gpu", False))
if not _gpu_used:
_gpu_name = None
warnings = ["PyFock uses density fitting with def2-universal-jfit."]
if (
not _gpu_used
and request.options.get("use_gpu") is not False
and _gpu_reason
):
warnings.append(f"PyFock GPU unavailable; CPU fallback: {_gpu_reason}")
if not bool(getattr(dft, "converged", False)):
warnings.append("PyFock reached its iteration limit without convergence.")

Expand All @@ -175,6 +193,8 @@ def _run_single_point(self, request: EngineRequest) -> EngineResult:
formula=_formula(atoms),
homo_lumo_gap_ev=gap_ev,
warnings=warnings,
gpu_used=_gpu_used,
gpu_name=_gpu_name,
)
native = result.to_session_result()
_attach_analysis(native, mol, basis, dft, density, atoms, coordinates, warnings)
Expand Down Expand Up @@ -202,6 +222,7 @@ def _run_geometry_opt(self, request: EngineRequest) -> EngineResult:
expected_steps=request.options.get("expected_steps"),
engine_id=self.engine_id,
ncores=_positive_int(request.options.get("ncores"), default=1),
use_gpu=request.options.get("use_gpu"),
)
return EngineResult(
request_id=request.request_id,
Expand All @@ -213,6 +234,8 @@ def _run_geometry_opt(self, request: EngineRequest) -> EngineResult:
method=native.method,
basis=native.basis,
formula=native.formula,
gpu_used=getattr(native, "gpu_used", False),
gpu_name=getattr(native, "gpu_name", None),
native_result=native,
)

Expand Down
5 changes: 5 additions & 0 deletions quantui/engines/pyscf_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ def run(self, request: EngineRequest) -> EngineResult:
basis=native.basis,
formula=native.formula,
homo_lumo_gap_ev=native.homo_lumo_gap_ev,
gpu_used=native.gpu_used,
gpu_name=native.gpu_name,
native_result=native,
)

Expand All @@ -137,6 +139,7 @@ def _run_geometry_opt(self, request: EngineRequest) -> EngineResult:
resume=bool(request.options.get("resume", False)),
scf_rescue=bool(request.options.get("scf_rescue", True)),
engine_id=self.engine_id,
use_gpu=request.options.get("use_gpu"),
)
return EngineResult(
request_id=request.request_id,
Expand All @@ -148,6 +151,8 @@ def _run_geometry_opt(self, request: EngineRequest) -> EngineResult:
method=native.method,
basis=native.basis,
formula=native.formula,
gpu_used=getattr(native, "gpu_used", False),
gpu_name=getattr(native, "gpu_name", None),
native_result=native,
)

Expand Down
Loading
Loading