QSpace: fix the Schur fill between repeated irreps, and make the q-space path linear in memory - #35
QSpace: fix the Schur fill between repeated irreps, and make the q-space path linear in memory#35SorBalda wants to merge 6 commits into
Conversation
The four q-space kernels allocated x_rot and y_rot inside the (configuration, symmetry) loop, so each iteration produced two fresh ComplexF64 vectors of length n_q*n_bands for the GC. They are overwritten every iteration and never outlive it, so they are hoisted out and filled in place with mul!. LinearAlgebra is already imported and the symmetry operators are square, so the 3-argument mul! overwrites the whole destination. This makes the buffers shared across the loop: parallelizing it would require making them thread-local. There is no @threads in this file, and a note to that effect sits next to each buffer.
Filling G on a degenerate block used the scalar shortcut c*I for the cross
block between two blocks as well. By Schur's lemma the diagonal block of a
d-dimensional irrep copy is c*I in any orthonormal basis of that copy, but the
cross block between two copies of the SAME irrep is c*U, with a unitary
intertwiner U that is not the identity: the blocks come from degeneracy in
frequency, and eigh picks an arbitrary basis inside each degenerate subspace,
so the two bases are unrelated. The scalar shortcut is exact only where the
coupling vanishes, i.e. between distinct irreps.
The resulting error is symmetric by construction, so it survives the final
symmetrization and leaves no diagnostic.
_adaptive_schur_fill measures the coupling on the already-solved
representative columns, with threshold min(50*tol, 1e-5)*scale, groups the
coupled blocks with union-find, and solves those groups column by column
exactly. Distinct irreps keep the scalar path, so the cost is d-1 extra column
solves per coupled group and nothing changes where the shortcut was valid. A
false positive only costs solves.
Measured on a cubic LaAlO3 supercell (5 atoms per cell, 27 q points, 405
modes), against the exact reference obtained with use_mode_symmetry=False,
which solves every non-acoustic band as its own column and therefore makes no
Schur assumption at all:
scalar shortcut max|dPhi| = 6.0e-02 relative = 2.0e-01
adaptive fill max|dPhi| = 2.3e-09 relative = 7.8e-09
so the shortcut is off by 20% on this system while the adaptive fill matches
the exact solution. The detection fires at 3 of the 27 q points.
tests/test_qspace/test_schur_fill.py drives _adaptive_schur_fill with an exact
solver, so it needs neither Julia nor ensemble data: it checks that a repeated
irrep with a nontrivial intertwiner is reproduced, that distinct irreps still
take the scalar path, and that singletons are unaffected.
Note for review: what is still missing is a physics-level regression that runs
in CI. The LaAlO3 comparison above is a manual three-way run against an
ensemble that is not in the test data, and the unit tests, while exact, are
synthetic. A small committed ensemble exercising a repeated irrep would make
this permanently checkable, and we are happy to add one if you can point at a
suitable system.
…allbacks The q-space Lanczos allocated the dense (3N, 3N) supercell polarization matrix even though it only ever uses the per-q eigenmodes. It now asks CellConstructor for DiagonalizeSupercell(q_only=True) and builds the ensemble with sscha's Ensemble(qspace_light=True), which keeps the whole setup linear in the supercell size rather than quadratic. Both of those live in the companion packages, so their absence is detected rather than assumed. Two signature probes decide at import and at load time: * no q_only in CellConstructor -> fall back to return_qmodes with a warning. Same numbers, but the dense matrix is allocated, so the path is no longer linear. mode_iq/mode_band are stored only, never consumed, and are left None. * no Ensemble(qspace_light=) in python-sscha -> the distributed loader builds a standard ensemble instead of tripping the frozen-attribute hook. This has to be checked before building the light ensemble, because Ensemble.__setattr__ calls DiagonalizeSupercell(q_only=True) as soon as the flag is set: without the check the failure happens there, before this class exists and before its own fallback could apply. __JULIA_EXT__ cannot carry either decision: it only means "juliacall is importable", which says nothing about the runtime or about the companion APIs. load_distributed_tdscha also checks up front, before any I/O, that the ensemble class exposes the q-space cache API: otherwise the failure came only after load_bin and a real-space update_weights had already been paid for. Other fixes here: * N_eff is kept as a float. int() truncated the weighted count of each MPI slice, and Julia normalizes by the exact sum of rho. * units="hartree" raises NotImplementedError on the q-space path instead of silently rescaling: the Fourier caches are built with Ry/Angstrom factors and Ensemble.convert_units does not update them. * the symmetrization refuses to build a wrong matrix when a symmetry operation does not map the atoms onto themselves within tolerance, and point-group operations that do not preserve an anisotropic q grid are skipped with a warning (they form a subgroup, so averaging over the rest is still a valid projector) instead of crashing. * the ensemble's cache coherence marker is no longer written from here; the public refresh method on the ensemble raises it.
mesonepigreco
left a comment
There was a problem hiding this comment.
Review pass 2 — code hygiene / dependency contract (follow-up to the Schur-fill review).
The Schur analysis in commit 3 is solid (verified independently: the old shortcut is off by ~20% on a repeated-irrep pair, the adaptive fill is exact). This review is about the integration code in commit 3 (QSpaceLanczos.py), which currently works around the companion packages with runtime introspection instead of declaring the contract up front. Three points, all inline:
- Pin the versions in
requirements.txt/pyproject.tomland delete theinspect.signaturecapability probes (_cc_has_q_only,_ensemble_supports_light, line 67-88) — probing the internal API of CellConstructor/python-sscha is fragile and makes the fallback paths dead code that CI can never exercise. Thereturn_qmodes=Truedense fallback in__init__and the standard-ensemble fallback inload_distributed_hessianshould go away with them. - Top-module imports only:
import inspect as _inspectis mid-file andimport sscha.Ensembleis inside a function (line 81). - No
hasattr(lines 1872-1873): therefresh_qspace_caches_from_real_space/_refresh_qspace_caches_from_real_spaceprobe is a third, mutually inconsistent way of detecting the same capability; with the pinned version it is simply removable.
Both requirements.txt and pyproject.toml currently list bare cellconstructor / python-sscha (no minimum), which is what made these probes feel necessary — fix it at the source.
mesonepigreco
left a comment
There was a problem hiding this comment.
Review pass 3 — same import hygiene rule applied to the new test (test_neff_cast.py): top-module imports only. Two inline comments on run_mpi and _load_dyns.
mesonepigreco
left a comment
There was a problem hiding this comment.
Good, there are some edge cases for the new Shurr implementation that can be improved (see comments within the file changes, not sure it is good). Then there are multiple things that needs to be changed: the code use inspect which is very hacky. This should be removed and replaced with a hard constraint on cellconstructor and python-sscha versions (1.7, i.e., the next version). Also, imports must be module level, while tests are using it function level, which also feels hacky.
Review feedback from @mesonepigreco: the detection never ran for a single degenerate block, because it was guarded by len(deg_blocks) > 1. A block can be reducible on its own -- a repeated irrep at the same frequency, or an accidental degeneracy between different irreps -- and then c*I is wrong for it with no partner block to reveal it. Schur forces G on one irrep copy to be c*I in any basis, so the representative column of a clean copy has zero support on the rest of its own block: nonzero leakage exposes the reducible case, and it is measured on a column that is solved anyway. The dimension shortcut is relaxed accordingly: "different dimension -> different irrep" only holds when both blocks are irreducible. Detection is split into two passes rather than done in a single loop, for two reasons. The self-reducible set must be complete before any pair is examined, otherwise the result depends on the order the blocks happen to be listed in (new regression test). And the threshold scale is recomputed per pair instead of carried as a running maximum: a monotonically non-decreasing threshold can only lose couplings, and column norms go as 1/w^2, so a single soft-mode block would raise it by orders of magnitude for every pair examined after it. Three regression tests are added; two of them fail on the previous code. Also from the review: the inspect import moved to the header block, the redundant function-local sscha.Ensemble import dropped, and the duplicated hasattr probe folded into _ensemble_has_qspace_cache_api. The probes themselves are kept and now carry a note: no released CellConstructor or python-sscha exposes q_only / qspace_light, so the version pin that would replace them cannot be written yet.
|
Thanks — the single-block gap is real and I had missed it. Pushed in e74a90c, together with the import cleanup. Reproduced your leakage numbers exactly: Two changes to the patch, both needed. I split the detection into two passes instead of doing it in one loop:
Three regression tests added ( Cost formula: you are right, and the PR body is fixed — One limit worth recording, which neither criterion covers: if On pinning the versions. I'd like to, but it isn't writable yet: no released version exposes the API. PyPI There is also a detail that cuts the other way: Proposal: keep the probes for now with the note that is in the code, and open a follow-up that pins the versions and deletes probes and fallbacks the day #126 ships. If you'd rather have the pin now and merge the three PRs together, that works too — just say which order you want them in. Separately, |
…sian test The spectator block in test_soft_mode_spectator_does_not_raise_the_threshold was given a tiny constant, so its column norm was the smallest of the three instead of the largest and the running-maximum threshold never moved: the test passed on every variant and guarded nothing. Its whole point is a spectator whose column dominates, so the constant is now 1e3 (a larger ratio makes the matrix ill-conditioned enough to trip the accuracy assert for the wrong reason). With the fix the test fails on the single-loop detection and passes on the two-pass one, as intended. Both new asserts on the detected set are tightened from a subset test to equality, so a variant that gives up and solves everything no longer passes. test_distributed_hessian built the Hessian with the spatial symmetries off but left the mode symmetry on, and asserted non-negative eigenvalues at Gamma. It passed only because the scalar cross-block fill fabricated a degeneracy that replaced the true eigenvalues with zeros; with the fill corrected it reports the same spectrum as use_mode_symmetry=False, whose two lowest eigenvalues are genuinely slightly negative (order 1e-6 Ry/bohr^2) for a 10-configuration ensemble with no symmetries. The test now turns the mode symmetry off, to match the rest of the object, and checks that the spectrum is finite and not far below zero instead of asserting a positivity the data does not have. Two further limits of the criterion are documented: the coupling between a singleton mode and an accidentally degenerate block is zeroed without being measured, and the relative threshold is a floor on sensitivity.
|
Corrections to my previous comment, found by re-reviewing my own tests before you got to them. Pushed in 7e0753b. 1. I was wrong to downgrade the Corrected, with the spectator's column norm ~10³ times the coupled pair: So the single-loop version loses a real 2. 3. 4. This PR changes a number under MPI, and the new one is the correct one. The corrected fill reproduces the no-symmetry ground truth exactly. For a 10-configuration ensemble with symmetries off, the two lowest eigenvalues really are slightly negative, so I turned the mode symmetry off in that test to match the rest of the object and replaced the positivity assert with finiteness plus a loose bound, with the reason in a comment. 5. Two more limits of the criterion, now documented in the docstring: the coupling between a singleton mode and an accidentally degenerate block is zeroed without ever being measured (Schur forbids coupling between different irreps, not different frequencies), and Unchanged and still correct: the |
There is no reason for the lazy form here: JuliaExt boots the runtime on first use, not on import, so collecting this module does not start Julia, and test_distributed.py already imports the same modules at module scope.
QSpace: fix the Schur fill between repeated irreps, and make the q-space path linear in memory
Three commits, reviewable independently. The first is a correctness fix and does
not depend on anything else.
Part of a three-repository series:
DiagonalizeSupercell(q_only=True))Ensemble(qspace_light=True))This PR is safe on its own: with unpatched companion packages it detects
their absence and falls back with an explicit warning, rather than failing. But
the memory gain only materializes when all three are in, since the two
primitives live in the other repositories.
1.
QSpaceHessian: the Schur fill between repeated irreps is wrongFilling
Gon a degenerate block used the scalar shortcutc*Ifor the crossblock between two blocks as well. By Schur's lemma the diagonal block of a
d-dimensional irrep copy is
c*Iin any orthonormal basis of that copy, butthe cross block between two copies of the same irrep is
c*U, with a unitaryintertwiner
Uthat is not the identity: the blocks come from degeneracy infrequency, and
eighpicks an arbitrary basis inside each degenerate subspace,so the two bases are unrelated. The shortcut is exact only where the coupling
vanishes, i.e. between distinct irreps.
The resulting error is symmetric by construction, so it survives the final
symmetrization and leaves no diagnostic.
_adaptive_schur_fillmeasures the coupling on the already-solvedrepresentative columns, groups the coupled blocks with union-find, and solves
those groups column by column exactly. Distinct irreps keep the scalar path, so
nothing changes where the shortcut was valid, and a false positive only costs
solves. Cost: every block of a coupled group is solved in full, so a group of
kblocks of dimensiondcostsk(d-1)extra column solves — the earlierwording said
d-1per group, which was wrong.Measured on a cubic LaAlO3 supercell (5 atoms/cell, 27 q points, 405 modes,
200 configurations), against the exact reference obtained with
use_mode_symmetry=False, which solves every non-acoustic band as its owncolumn and therefore makes no Schur assumption at all. All three Hessians come
from the same ensemble, at
tol=1e-10:| |
max |dPhi|| relative ||---|---|---|
| scalar shortcut (current) | 3.3e-02 | 8.4e-02 |
| adaptive fill (this PR) | 1.5e-09 | 4.0e-09 |
The shortcut is off by 8% on this system; the adaptive fill matches the exact
solution to nine digits. The detection fires at 3 of the 27 q points. The size
of the error depends on the ensemble it is measured on -- the same comparison
at 200 vs 20 configurations gives 8% and 20% -- but its order of magnitude does
not, and it is always many orders above the adaptive fill.
This also means that turning mode symmetry on currently degrades the result,
which matches what we had been seeing empirically before finding the cause.
tests/test_qspace/test_schur_fill.pydrives_adaptive_schur_fillwith anexact solver, so it needs neither Julia nor ensemble data.
2.
tdscha_qspace.jl: preallocate the symmetry-rotation buffersThe four q-space kernels allocated
x_rotandy_rotinside the(configuration, symmetry)loop, producing two freshComplexF64vectors oflength
n_q*n_bandsper iteration for the GC. They are overwritten everyiteration and never outlive it, so they are hoisted out and filled in place with
mul!.This makes the buffers shared across the loop: parallelizing it would require
making them thread-local. There is no
@threadsin this file, and a note sitsnext to each buffer.
3.
QSpaceLanczos: linear-memory path, with explicit capability fallbacksThe q-space Lanczos allocated the dense
(3N, 3N)supercell polarization matrixeven though it only ever uses the per-q eigenmodes. It now asks for
DiagonalizeSupercell(q_only=True)and builds the ensemble withEnsemble(qspace_light=True).Both live in the companion packages, so their absence is detected rather than
assumed, by two signature probes:
q_only→ fall back toreturn_qmodeswith a warning. Same numbers, butthe dense matrix is allocated, so the path is no longer linear.
Ensemble(qspace_light=)→ the distributed loader builds a standardensemble. This must be checked before building the light ensemble, because
Ensemble.__setattr__callsDiagonalizeSupercell(q_only=True)as soon as theflag is set: otherwise the failure happens there, before this class exists.
__JULIA_EXT__cannot carry either decision: it only means "juliacall isimportable", which says nothing about the runtime or about the companion APIs.
load_distributed_tdschaalso checks up front, before any I/O, that the ensembleexposes the q-space cache API — otherwise the failure came only after
load_binand a real-space
update_weightshad already been paid for.Other fixes in this commit:
N_effis kept as a float.int()truncated the weighted count of eachMPI slice, while Julia normalizes by the exact
sum(rho).units="hartree"raisesNotImplementedErroron the q-space path instead ofsilently rescaling: the Fourier caches are built with Ry/Angstrom factors and
Ensemble.convert_unitsdoes not update them.does not map the atoms onto themselves within tolerance; point-group
operations that do not preserve an anisotropic q grid are skipped with a
warning (they form a subgroup, so averaging over the rest is still a valid
projector) instead of crashing.
Testing.
tests/test_qspace/passes (26 passed, 7 skipped). The q-space andreal-space free-energy Hessians agree to 3.6e-5 cm-1 on LaAlO3 3x3x3 (405
modes, 200 configurations, same ensemble).
test_neff_cast.pymoves fromwork/intotests/test_qspace/.