Improve Vxc Screening - #100
Jens (JensWehner) wants to merge 39 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces global spatial AO screening for Skala’s PySCF and GPU4PySCF integrations, evaluating AO-derived features in spatially grouped blocks (with atom-major restoration) and using chunk-local autograd plus blockwise VJP/JVP to bound memory while preserving a dense fallback.
Changes:
- Add a global screened feature-evaluation path (spatial grid sorting + blockwise AO screening) for first- and second-order XC / response computations.
- Update memory estimation to account for full-grid raw-feature/cotangent buffers and add targeted unit tests for the estimators.
- Expand CPU/GPU test coverage for screening decisions, ordering/permutations, dense-vs-screened equivalence, and profiling/benchmark harnesses (with new pytest markers/deps).
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_memory_estimators.py | Adds unit tests for the new global raw-feature buffer memory estimator and reserved-memory chunk sizing. |
| tests/test_gpu4pyscf_gradients.py | Extends GPU gradient tests to compare CPU/GPU and dense/screened routes against stored references. |
| tests/test_gpu4pyscf_classes.py | Marks GPU4PySCF class tests with a GPU marker and adjusts import ordering for CUDA gating. |
| tests/test_gpu4pyscf_ao_screening.py | Adds GPU-focused AO screening tests covering sorting, response equivalence, masking/scatter, and CPU-vs-GPU validation. |
| tests/test_ao_screening.py | Adds CPU AO screening tests for screening decisions, permutations, masking behavior, and screened response correctness. |
| tests/test_ao_screening_benchmark.py | Adds benchmark/profiling tests to compare screened vs dense runtime and peak memory (CPU + CUDA). |
| src/skala/pyscf/numint.py | Switches atom-chunked path to global screened evaluation, adds _should_screen_aos, and updates response/HVP logic accordingly. |
| src/skala/pyscf/memory_estimators.py | Adds reserved-memory support and a new estimator for global raw-feature buffer storage. |
| src/skala/pyscf/gradients.py | Minor dict-comprehension cleanup while building gradient feature dictionaries. |
| src/skala/pyscf/features.py | Adds spatial grid ordering, caching, AO screening block loop helpers, and global screened feature evaluation machinery. |
| src/skala/pyscf/backend.py | Reorders/cleans __all__ exports. |
| src/skala/gpu4pyscf/gradients.py | Minor dict-comprehension cleanup mirroring the CPU gradients module. |
| src/skala/functional/utils/irreps.py | Reorders __slots__ entries. |
| src/skala/functional/load.py | Simplifies all(...) checks (removes unnecessary list creation). |
| src/skala/functional/init.py | Reorders __all__ exports. |
| src/skala/ase/init.py | Removes a now-redundant # noqa: F401 from the Skala export. |
| pyproject.toml | Adds pytest timeout/addopts/markers and new dev dependencies (benchmark/timeout/memray). |
| environment-gpu.yml | Adds pytest-benchmark and pytest-timeout to the GPU development/test environment. |
| environment-cpu.yml | Adds pytest-benchmark and pytest-timeout to the CPU development/test environment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
9aca3f7 to
60385d5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/skala/pyscf/xc_integrator.py:292
sorted_raw_features(and the model-chunk raw features derived from it) are computed fromdm0.double(), butscreened_feature_jvp()is called withdm0/dm1directly. Ifdm0/dm1are not float64 this will produce a tangent with a different dtype thanlocal_raw_features, and the subsequenttorch.autograd.grad(..., grad_outputs=atom_major_tangent[...])will error due to dtype mismatch.
atom_major_tangent = screened_feature_jvp(
dm0,
dm1,
mol,
spatial_grid_layout,
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (7)
tests/test_xc_integrator.py:4
tests/utils.pyis not importable as a top-levelutilsmodule (there is noutils.pyat the repository root), so this import will fail under pytest. Import the helper from thetestsnamespace instead.
tests/test_ao_screening.py:7tests/utils.pyis not importable as a top-levelutilsmodule (there is noutils.pyat the repository root), so this import will fail under pytest. Import the helper from thetestsnamespace instead.
from utils import QuadraticFunctional, patch_ao_screening
tests/test_gpu4pyscf_ao_screening.py:26
tests/utils.pyis not importable as a top-levelutilsmodule (there is noutils.pyat the repository root), so this import will fail under pytest. Import the helper from thetestsnamespace instead.
from utils import QuadraticFunctional, patch_ao_screening # noqa: E402
tests/test_ao_screening_benchmark.py:17
tests/utils.pyis not importable as a top-levelutilsmodule (there is noutils.pyat the repository root), so this import will fail under pytest. Import the helper from thetestsnamespace instead.
from utils import patch_ao_screening
tests/test_gpu4pyscf_gradients.py:26
tests/utils.pyis not importable as a top-levelutilsmodule (there is noutils.pyat the repository root), so this import will fail under pytest. Import the helper from thetestsnamespace instead.
src/skala/functional/density.py:20IMMUTABLESis typed asfrozenset[str]but it containsFeatureenum values. This will confuse type-checking (and IDEs) and can hide real key-type mistakes when working withFeatureMap.
IMMUTABLES: frozenset[str] = frozenset([Feature.GRID_COORDS, Feature.GRID_WEIGHTS])
src/skala/ase/init.py:11
- This import is only used to re-export
Skalavia__all__, so Ruff/Pyflakes will likely flag it asF401(imported but unused) without an explicit ignore (consistent withsrc/skala/foundry/__init__.py).
from skala.ase.calculator import Skala
There was a problem hiding this comment.
I would ignore these files, in this folder, I am not sure if they should be committed.
| - name: Run mypy | ||
| run: mypy . | ||
| shell: micromamba-shell {0} |
There was a problem hiding this comment.
Can we keep mypy here to run it in our production environment rather than the incomplete pre-commit env?
There was a problem hiding this comment.
yes, the precommit mypy at the moment uses the local mypy version so it should use the full environment.
There was a problem hiding this comment.
Do we want to keep this large notebooks as part of the repo?
There was a problem hiding this comment.
probably not, I am not sure how much of the benchmarking code we should keep.
| spatial_grid_layout = cast( | ||
| SpatialGridLayout | None, | ||
| grid_state.get("_skala_spatial_grid_layout"), | ||
| ) | ||
| if spatial_grid_layout is not None: | ||
| return spatial_grid_layout |
There was a problem hiding this comment.
Is this caching save in case we use a GradientScanner as in the ASE calculator, where we reuse the Kohn-Sham object and the Grids object but just reset them with new molecule geometries?
There was a problem hiding this comment.
Good catch, I did not think of grid object reuse, so I would have to hook that into the grid_object reset. That is annoying. Why would you ever reset a grid object, sigh.
There was a problem hiding this comment.
then I guess I have to create two classes as well which inherit from the respective Gridobjects.
| dm_eval = dm.double() | ||
| electron_count = torch.zeros(2, device=self.device, dtype=dm_eval.dtype) | ||
| energy = torch.tensor(0.0, device=self.device, dtype=dm_eval.dtype) | ||
| feature_function = feature_math.MGGAFeatureFunction(self.feature_spec) |
There was a problem hiding this comment.
Should we add update the feature spec here as in _integrate_dense with set(self.feature_spec.names) | {Feature.DENSITY, Feature.GRID_WEIGHTS}?
There was a problem hiding this comment.
yes, good point.
| blksize = _resolve_ao_block_size(mol, feature_function, block_size, max_memory, gpu) | ||
|
|
||
| if blksize is not None and blksize >= grids.weights.shape[0]: | ||
| features = evaluate_full_grid(dm.double(), mol, grids.coords, feature_function) |
There was a problem hiding this comment.
Should this pass through the gpu option?
| features = evaluate_full_grid(dm.double(), mol, grids.coords, feature_function) | |
| features = evaluate_full_grid(dm.double(), mol, grids.coords, feature_function, gpu) |
There was a problem hiding this comment.
Thanks, ohh yes.
Sebastian Ehlert (awvwgk)
left a comment
There was a problem hiding this comment.
Left some comments, overall looks good to me.
One note, the gradient calculation is currently not affected by this update is it? We would still use the dense path there, so the ASE calculator which goes via the GradientScanner would see the same peek memory / compute usage as before?
Derk Kooi (DerkKooi)
left a comment
There was a problem hiding this comment.
Further simplifications are possible on the custom autodiff side because of the assumption of features linear in the density matrix. I have described the modifications in some detail in this review.
| return loop_type(dm, mol, grids, feature_function, blksize) | ||
|
|
||
|
|
||
| class ChunkEvalForward(Function): |
There was a problem hiding this comment.
If we are assuming that our features are only linear in the DM then we can (and I think should) go one step further in the simplification of the two custom autograd functions (both ChunkEvalForward and ChunkEvalBackward). I've outlined the simplifications in separate comments.
| context.blksize, | ||
| context.compile_feature_function, | ||
| context.gpu, | ||
| *vectors_jvp, |
There was a problem hiding this comment.
vectors_jvp can be done away with everywhere, since any jvp of a linear function of dm (func(dm)) is just that same linear function evaluated on the v of the jvp (func(v)).
| device=ctx.dm.device, | ||
| dtype=ctx.dm.dtype, | ||
| ) | ||
| vector_tangent = grad_inputs[7] if ctx.vectors_jvp else grad_inputs[0] |
There was a problem hiding this comment.
this just becomes vector_tangent=grad_inputs[0]
| dtype=ctx.dm.dtype, | ||
| ) | ||
| return ChunkEvalForward.apply( | ||
| ctx.dm, |
There was a problem hiding this comment.
and here replace ctx.dm by vector_tangent.
| if ctx.vectors_jvp: | ||
| dm_grad = ctx.dm * 0 | ||
| else: | ||
| dm_grad = ChunkEvalBackward.apply( |
There was a problem hiding this comment.
ChunkEvalBackward doesn't need dm, so feature_cotangent can be added as the first argument instead.
| *vectors_jvp, | ||
| ) = inputs | ||
| context.vectors_jvp = tuple(vectors_jvp) | ||
| ctx.save_for_backward(context.dm) |
There was a problem hiding this comment.
Linear function means we don't have to save the dm for the backward.
| context.gpu, | ||
| _feature_cotangent, | ||
| ) = inputs | ||
| ctx.save_for_backward(context.dm) |
There was a problem hiding this comment.
Don't need to save the dm or the _feature_cotangent for the backward.
| def jvp( | ||
| ctx: _ChunkEvalBackwardContext, *grad_inputs: torch.Tensor | None | ||
| ) -> torch.Tensor: | ||
| feature_cotangent_tangent = grad_inputs[7] |
There was a problem hiding this comment.
feature_cotangent_tangent = grad_inputs[0]
| if feature_cotangent_tangent is None: | ||
| return torch.zeros_like(ctx.dm) | ||
| return ChunkEvalBackward.apply( | ||
| ctx.dm, |
There was a problem hiding this comment.
feature_cotangent_tangent as first argument.
| grads: list[Tensor | None] = [ctx.dm * 0] | ||
| grads += [None] * 6 | ||
| grads.append( | ||
| ChunkEvalForward.apply( |
There was a problem hiding this comment.
First argument is grad_outputs[0], check again ctx.requires_grad[0].
Yes, correct. I did not look at gradients at all so far. |
|
So talking to Thijs Vogels (@tvogels) to make the review much easier I split this up into multiple PRs. |
Branch changes compared with main
This summary compares the current
screeningbranch withorigin/main. The localmainbranch is one commit behindorigin/main; using it literally would also includean unrelated upstream allocator commit. Uncommitted files are excluded.
Automatic AO screening for large systems
XC evaluation now switches from dense AO contractions to screened contractions when
the functional supports atom-aligned grids and
mol.nao_nr()exceeds PySCF'sSWITCH_SIZE. Smaller systems and unsupported functionals retain the dense path.Spatially localized integration grids
Grid points are recursively grouped into spatial blocks so each block touches fewer
basis functions. Forward and inverse permutations restore the atom-major ordering
expected by Skala.
Reusable, backend-aware screening metadata
A
SpatialGridLayoutis cached on the original grid and shared acrossSkalaNumIntinstances. CPU evaluation rebuilds PySCF shell masks, while GPU evaluation invalidates
GPU4PySCF's AO-index cache for the reordered grid.
Sparse blockwise AO autograd
New custom PyTorch autograd functions evaluate only active AO submatrices and scatter
derivatives back into full density-matrix shapes. They handle CPU shell masks, GPU AO
sorting, empty blocks, VJPs, JVPs, and higher-order differentiation.
Screened energy and potential integration
The screened first-order path evaluates raw AO features globally once, processes the
neural functional in memory-sized atom chunks, collects feature cotangents, and
performs one final sparse VJP to obtain the XC potential.
Screened response and Hessian-vector products
Response evaluation computes a screened feature JVP, chunk-local model Hessian
actions, and a final sparse AO VJP.
XCIntegratorreturns the XC-only response, whileSkalaNumIntadds the Coulomb response required by PySCF.AO traversal and model chunking are decoupled
AO screening traverses the spatial grid independently of neural-model batching. Model
chunks preserve complete atomic grids and are sized using available memory after
reserving space for global feature and cotangent buffers.
PySCF integration is split into focused modules
The former large
features.pymodule is now a thin public feature generator. Featurepolicy, feature mathematics, AO evaluation, screening, model chunking, and XC
integration have separate owners;
SkalaNumIntprimarily adapts NumPy and CuPy datato Torch.
Feature support is deliberately narrowed
The unsupported nonlinear kinetic-tensor features
ked_varandked_detwereremoved. Screened raw features are restricted to density-matrix-linear meta-GGA
quantities:
density,grad,kin, andlapl. Directsecond_order=Truecalls nowraise an error in favor of
gen_response(). The vjp for the pullback from model outputs to echange correlation potential now uses an analytical form instead of pytorch autograd.Correctness and performance coverage is substantially expanded
New tests compare dense and screened CPU/GPU results for RKS, UKS, the XC potential,
response/HVP, sparse and empty AO blocks, and nuclear gradients. Benchmark
infrastructure measures runtime and peak memory, with
pytest-benchmark,pytest-timeout, Memray support, and dedicated benchmark, GPU, and profiling markers.Comparison of main branch and this branch vs fully dense evaluation


runtime:
max memory: