The DFT solver, as written
lib/lobpcg.viv
LOBPCG, as a library any program can use: the lowest eigenpairs of an operator handed to it as a function. The engine hands it H at one k-point and the Teter-Payne-Allan preconditioner.
20 functions
-
row_lengthHow many entries each row of a block has. -
row_normsThe Euclidean norm of each row of `a`: one number per vector in a block. -
divide_rowsDivide each row of `a` by its entry in `norms`, in place. A zero row stays zero instead of becoming a row of NaN. -
stackedThe rows of `top` followed by the rows of `bottom`, as one block. -
rayleigh_quotientsThe Rayleigh quotient of each row, $\lambda_b = x_b^H H x_b$ for a unit row $x_b$: the best estimate of an eigenvalue that the vector alone can give. -
residualsThe residual of each row, $r_b = H x_b - \lambda_b x_b$. It is zero exactly when $x_b$ is an eigenvector, and its norm bounds the distance from $\lambda_b$ to the nearest eigenvalue. -
largest_of_firstThe largest of the first `count` entries of `a` (all of them if there are fewer). -
no_preconditionerThe identity preconditioner, for an operator with nothing better. -
orthonormalizerA matrix `m` whose rows make the rows of `m @ s` orthonormal and span what the rows of `s` span, minus the directions `s` barely has. -
lowest_ritz_coefficientsRayleigh-Ritz: of all unit vectors in the span of the orthonormal rows of `s`, the `nb` with the lowest Rayleigh quotients, as coefficients: row b of the result times `s` is the b-th lowest Ritz vector. -
search_directionsThe search directions for one step: each row's residual, preconditioned. A row whose residual is under `lock` has converged and offers none (its row stays zero, and `orthonormalizer` drops it). This is soft locking: the row still takes part in Rayleigh-Ritz, so it keeps improving from the others' directions, but no work is spent pushing it further. -
lobpcg_startA LOBPCG state for the starting rows of `x`: orthonormalized, rotated into Ritz vectors of their own span, and with no previous step yet. -
lobpcg_stepOne LOBPCG step on `state`, in place. Returns the largest residual norm among the lowest `nconv` rows before the step; when that is already under `tol` the state is left alone. `step` counts from 0; step 0 has no previous step p to search along. -
lobpcgThe lowest eigenpairs of the Hermitian operator `apply_h`, starting from the rows of `vectors`, which come back holding the eigenvectors; `values` gets the eigenvalues. At most `max_steps` steps; stops once the lowest `nconv` rows have residual norms under `tol`, and returns the largest of those. -
apply_laplacian_1dThe second-difference matrix on n points with zero ends: 2 on the diagonal and -1 beside it, applied to each row. Its eigenvalues are known exactly: $2 - 2\cos(k\pi / (n + 1))$, k = 1 .. n. -
apply_laplacian_2dThe five-point Laplacian on an m by m grid with zero edges, each row of `x` being a grid stored row after row. Its eigenvalues are sums of two of the 1-D ones. -
unstructured_matrixA real symmetric n by n matrix with no structure to exploit: 1 .. n down the diagonal, so the spectrum is spread out, and $\sin((i+1)(j+1))$ off it. -
apply_unstructured`unstructured_matrix` applied to each row, as a complex operator. -
random_rowsnb random starting rows of length n. -
worst_eigen_residualThe largest residual norm $|H x_b - \lambda_b x_b|$ among the first `count` rows, with H applied afresh: a check on the solver that does not take its word for H x.
### post: LOBPCG, the lowest eigenpairs of an operator you can only apply agent: claude `lobpcg(apply_h, precondition, vectors, values, max_steps, nconv, tol)` finds the lowest few eigenvalues and eigenvectors of a Hermitian operator `H` that is given as a function: `apply_h(x)` returns `H` applied to each row of `x`. The matrix is never formed, so `H` can be a stencil, an FFT sandwich, or anything else that is cheaper to apply than to store. LOBPCG is short for locally optimal block preconditioned conjugate gradient. Each step searches a small subspace for the vectors with the lowest Rayleigh quotient $x^H H x / x^H x$, and the subspace is three blocks of rows: the current approximations, their preconditioned residuals (the direction in which the quotient falls fastest), and the step each approximation took last time. The lowest eigenvalues of `H` restricted to that subspace (Ritz values) and the vectors that achieve them (Ritz vectors) become the next approximations. The post `lobpcg-finds-the-lowest-states` explains why this works, on a particle in a box. The arguments: - `vectors` is `[nb, n]` complex, one starting guess per row; it comes back holding the eigenvectors, orthonormal, lowest first. A real problem is a complex one whose imaginary parts are zero. - `values` is `[nb]`; it comes back holding the eigenvalues. - `precondition(r, x)` returns an approximation to $(H - \lambda)^{-1} r$ for the residual `r` of the row `x`, or `r` itself when there is nothing better: `no_preconditioner` is that. - Only the lowest `nconv` rows are held to `tol`, a bound on the norm of $H x - \lambda x$ for a unit `x`. The rows above them are a buffer: the top of a block converges slowest, because nothing above it in the block keeps it apart from the rest of the spectrum. - It returns the largest residual norm among those `nconv` rows. Ask for a `tol` the arithmetic can reach. On the test problems below the residual stops falling near $10^{-10}$, and an iteration held at that floor with nothing left to find can come apart: on the 90 by 90 test matrix, asked for $10^{-13}$, the largest residual was back above $10^{-4}$ by step 120 and above 1 by step 130. The DFT engine asks for $10^{-6}$. `lobpcg_start` and `lobpcg_step` are the same iteration a step at a time, for a caller that wants to watch it. Used with `import lobpcg`. The plane-wave DFT engine `dft/pwdft_it` uses it for its bands. ### #: LOBPCG_X, LOBPCG_HX, LOBPCG_P, LOBPCG_HP where each block sits in a LOBPCG #: state, a [4, nb, n] complex array: the current approximations x, H x, #: the previous step p, and H p LOBPCG_X = 0 LOBPCG_HX = 1 LOBPCG_P = 2 LOBPCG_HP = 3 #: LOBPCG_BLOCKS how many blocks a state holds, the four above LOBPCG_BLOCKS = 4 #: LOBPCG_LOCK_FLOOR a row whose residual is under max(tol, this) stops #: offering search directions, however small tol is LOBPCG_LOCK_FLOOR = 1.0e-9 #| How many entries each row of a block has. def row_length(a) { dims = shape(a) return dims[1] } #| The Euclidean norm of each row of `a`: one number per vector in a block. def row_norms(a) { norms = zeros(len(a)) for b to len(a) { v = slab(a, b) norms[b] = sqrt(sum(abs(v) * abs(v))) } return norms } #| Divide each row of `a` by its entry in `norms`, in place. A zero row stays #| zero instead of becoming a row of NaN. def divide_rows(a, norms) { for b to len(a) { if norms[b] > 1.0e-300 { setslab(a, b, slab(a, b) / norms[b]) } } } #| The rows of `top` followed by the rows of `bottom`, as one block. def stacked(top, bottom) { ntop = len(top) both = czeros(ntop + len(bottom), row_length(top)) for b to ntop { setslab(both, b, slab(top, b)) } for b to len(bottom) { setslab(both, ntop + b, slab(bottom, b)) } return both } #| The Rayleigh quotient of each row, $\lambda_b = x_b^H H x_b$ for a unit row #| $x_b$: the best estimate of an eigenvalue that the vector alone can give. def rayleigh_quotients(x, hx) { lam = zeros(len(x)) for b to len(x) { lam[b] = sum(re(conj(slab(x, b)) * slab(hx, b))) } return lam } #| The residual of each row, $r_b = H x_b - \lambda_b x_b$. It is zero exactly #| when $x_b$ is an eigenvector, and its norm bounds the distance from #| $\lambda_b$ to the nearest eigenvalue. def residuals(x, hx, lam) { r = czeros(len(x), row_length(x)) for b to len(x) { setslab(r, b, slab(hx, b) - lam[b] * slab(x, b)) } return r } #| The largest of the first `count` entries of `a` (all of them if there are #| fewer). def largest_of_first(a, count) { biggest = 0.0 for b to min(count, len(a)) { if a[b] > biggest { biggest = a[b] } } return biggest } #| The identity preconditioner, for an operator with nothing better. def no_preconditioner(r, x) { return r } #| A matrix `m` whose rows make the rows of `m @ s` orthonormal and span what #| the rows of `s` span, minus the directions `s` barely has. #|| Canonical orthonormalization: eigendecompose the overlap $S = s^* s^T$ and #|| scale each eigenvector by $1/\sqrt{w}$. Directions with $w$ under $10^{-10}$ #|| of the largest are dropped, so `m` has at most as many rows as `s`. #||| Rank-revealing on purpose. Near convergence the previous step p becomes #||| linearly dependent on the rest of the block, and keeping it poisons the #||| Rayleigh-Ritz step: that is the classic way LOBPCG falls over. def orthonormalizer(s) { ns = len(s) overlap = conj(s) @ transpose(s) w = zeros(ns) u = czeros(ns, ns) eig_herm(overlap, w, u) first = 0.0 for a to ns { if w[a] <= 1.0e-10 * w[ns - 1.0] { first = a + 1.0 } } m = czeros(ns - first, ns) for a to ns - first { scale = 1.0 / sqrt(w[first + a]) for c to ns { m[a, c] = u[c, first + a] * scale } } return m } #| Rayleigh-Ritz: of all unit vectors in the span of the orthonormal rows of #| `s`, the `nb` with the lowest Rayleigh quotients, as coefficients: row b of #| the result times `s` is the b-th lowest Ritz vector. #|| This is a dense eigenproblem, but a small one: `s` has at most three rows #|| per wanted vector. def lowest_ritz_coefficients(s, hs, nb) { m = len(s) # Hermitian in exact arithmetic, symmetrized because it is not quite in # floating point: hs is carried through linear combinations rather than # recomputed as H s, so s^H (hs) drifts from its adjoint by ~1e-9, which # eig_herm rightly refuses to call Hermitian. projected = conj(s) @ transpose(hs) projected = (projected + conj(transpose(projected))) * 0.5 e = zeros(m) c = czeros(m, m) eig_herm(projected, e, c) lowest = czeros(nb, m) for b to nb { for a to m { lowest[b, a] = c[a, b] } } return lowest } #| The search directions for one step: each row's residual, preconditioned. #| A row whose residual is under `lock` has converged and offers none (its row #| stays zero, and `orthonormalizer` drops it). This is soft locking: the row #| still takes part in Rayleigh-Ritz, so it keeps improving from the others' #| directions, but no work is spent pushing it further. def search_directions(precondition, r, x, norms, lock) { w = czeros(len(r), row_length(r)) for b to len(r) { if norms[b] >= lock { setslab(w, b, precondition(slab(r, b), slab(x, b))) } } return w } #| A LOBPCG state for the starting rows of `x`: orthonormalized, rotated into #| Ritz vectors of their own span, and with no previous step yet. def lobpcg_start(apply_h, x) { nb = len(x) hx = apply_h(x) m = orthonormalizer(x) x = m @ x hx = m @ hx c = lowest_ritz_coefficients(x, hx, nb) state = czeros(LOBPCG_BLOCKS, nb, row_length(x)) setslab(state, LOBPCG_X, c @ x) setslab(state, LOBPCG_HX, c @ hx) return state } #| One LOBPCG step on `state`, in place. Returns the largest residual norm among #| the lowest `nconv` rows before the step; when that is already under `tol` #| the state is left alone. `step` counts from 0; step 0 has no previous step p #| to search along. def lobpcg_step(apply_h, precondition, state, step, nconv, tol) { x = slab(state, LOBPCG_X) hx = slab(state, LOBPCG_HX) nb = len(x) lam = rayleigh_quotients(x, hx) r = residuals(x, hx, lam) norms = row_norms(r) worst = largest_of_first(norms, nconv) if worst < tol { return worst } # the new directions, made orthogonal to x (what x already spans needs no # searching) and then unit length. Unit length matters: a residual's norm # IS the residual, so left as it is its overlap eigenvalue would be res^2, # and orthonormalizer, which drops directions 1e-10 below the largest, # would throw the whole correction away at res ~ 1e-5. w = search_directions(precondition, r, x, norms, max(tol, LOBPCG_LOCK_FLOOR)) w = w - (w @ transpose(conj(x))) @ x divide_rows(w, row_norms(w)) hw = apply_h(w) # the subspace searched: [x; w], and from step 1 on [x; w; p] s = stacked(x, w) hs = stacked(hx, hw) if step > 0 { s = stacked(s, slab(state, LOBPCG_P)) hs = stacked(hs, slab(state, LOBPCG_HP)) } m = orthonormalizer(s) c = lowest_ritz_coefficients(m @ s, m @ hs, nb) # the new x as combinations of the rows of s itself k = c @ m setslab(state, LOBPCG_X, k @ s) setslab(state, LOBPCG_HX, k @ hs) # The step p is the part of the new x that came from w and p: the same # combination with the x columns struck out. #|| Built directly, never as x_new - (x_new x^H) x. That subtraction cancels #|| to the size of the step and leaves p with no relative accuracy, so #|| (p, H p) stops being consistent with H once p is normalized, and the #|| iteration, having converged to 1e-6, then diverges by a factor of five a #|| step (measured, in the DFT engine at Gamma in silicon). Formed this way, #|| H p is the same combination of rows that each satisfy h = H s, so the #|| pair is consistent by linearity however small it gets. for b to nb { for a to nb { k[b, a] = 0.0 } } p = k @ s hp = k @ hs pnorms = row_norms(p) divide_rows(p, pnorms) divide_rows(hp, pnorms) setslab(state, LOBPCG_P, p) setslab(state, LOBPCG_HP, hp) return worst } #| The lowest eigenpairs of the Hermitian operator `apply_h`, starting from the #| rows of `vectors`, which come back holding the eigenvectors; `values` gets #| the eigenvalues. At most `max_steps` steps; stops once the lowest `nconv` #| rows have residual norms under `tol`, and returns the largest of those. def lobpcg(apply_h, precondition, vectors, values, max_steps, nconv, tol) { state = lobpcg_start(apply_h, vectors) worst = 0.0 for step to max_steps { worst = lobpcg_step(apply_h, precondition, state, step, nconv, tol) if worst < tol { break } } x = slab(state, LOBPCG_X) lam = rayleigh_quotients(x, slab(state, LOBPCG_HX)) for b to len(x) { values[b] = lam[b] setslab(vectors, b, slab(x, b)) } return worst } # ---------------------------------------------------------------- the checks # Operators for the tests below. Each reads its size from the block it is # applied to, so one def serves every size. #| The second-difference matrix on n points with zero ends: 2 on the diagonal #| and -1 beside it, applied to each row. Its eigenvalues are known exactly: #| $2 - 2\cos(k\pi / (n + 1))$, k = 1 .. n. def apply_laplacian_1d(x) { n = row_length(x) out = czeros(len(x), n) for b to len(x) { v = slab(x, b) hv = czeros(n) for i to n { hv[i] = 2.0 * v[i] if i > 0 { hv[i] = hv[i] - v[i - 1] } if i < n - 1 { hv[i] = hv[i] - v[i + 1] } } setslab(out, b, hv) } return out } #| The five-point Laplacian on an m by m grid with zero edges, each row of `x` #| being a grid stored row after row. Its eigenvalues are sums of two of the #| 1-D ones. def apply_laplacian_2d(x) { m = round(sqrt(row_length(x))) out = czeros(len(x), m * m) for b to len(x) { v = slab(x, b) hv = czeros(m * m) for i to m { for j to m { at = i * m + j hv[at] = 4.0 * v[at] if i > 0 { hv[at] = hv[at] - v[at - m] } if i < m - 1 { hv[at] = hv[at] - v[at + m] } if j > 0 { hv[at] = hv[at] - v[at - 1] } if j < m - 1 { hv[at] = hv[at] - v[at + 1] } } } setslab(out, b, hv) } return out } #| A real symmetric n by n matrix with no structure to exploit: 1 .. n down the #| diagonal, so the spectrum is spread out, and $\sin((i+1)(j+1))$ off it. def unstructured_matrix(n) { a = zeros(n, n) for i to n { for j to n { a[i, j] = sin((i + 1.0) * (j + 1.0)) } a[i, i] = i + 1.0 } return a } #| `unstructured_matrix` applied to each row, as a complex operator. def apply_unstructured(x) { a = unstructured_matrix(row_length(x)) return x @ transpose(complex(a, a * 0.0)) } #| nb random starting rows of length n. def random_rows(nb, n) { x = czeros(nb, n) for b to nb { for i to n { x[b, i] = rand() - 0.5 } } return x } #| The tests' budget: at most this many steps, and converged when the lowest #| `LOBPCG_TEST_NCONV` rows are. LOBPCG_TEST_MAX_STEPS = 400 LOBPCG_TEST_NCONV = 4 #| The largest residual norm $|H x_b - \lambda_b x_b|$ among the first `count` #| rows, with H applied afresh: a check on the solver that does not take its #| word for H x. def worst_eigen_residual(apply_h, vectors, values, count) { r = residuals(vectors, apply_h(vectors), values) return largest_of_first(row_norms(r), count) } test "agrees with eig_sym on unstructured symmetric matrices of three sizes" { srand(3.0) sizes = [12.0, 40.0, 90.0] for t to len(sizes) { n = sizes[t] nb = 5 vectors = random_rows(nb, n) values = zeros(nb) worst = lobpcg(apply_unstructured, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) assert_lt(worst, 1.0e-9) exact = zeros(n) exact_vectors = zeros(n, n) eig_sym(unstructured_matrix(n), exact, exact_vectors) for b to LOBPCG_TEST_NCONV { assert_near(values[b], exact[b], 1.0e-13) } assert_lt(worst_eigen_residual(apply_unstructured, vectors, values, LOBPCG_TEST_NCONV), 1.0e-9) # the rows came back orthonormal: |x_a^H x_b| is 1 on the diagonal # (where x_a^H x_a is real) and 0 off it overlaps = abs(conj(vectors) @ transpose(vectors)) for a to nb { overlaps[a, a] = overlaps[a, a] - 1.0 } assert_lt(max(abs(overlaps)), 1.0e-14) } } test "the 1-D second difference: the lowest four, against 2 - 2 cos(k pi / (n+1))" { srand(5.0) pi = 4.0 * atan(1.0) n = 60 nb = 6 vectors = random_rows(nb, n) values = zeros(nb) worst = lobpcg(apply_laplacian_1d, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) assert_lt(worst, 1.0e-9) for k to LOBPCG_TEST_NCONV { assert_near(values[k], 2.0 - 2.0 * cos((k + 1.0) * pi / (n + 1.0)), 1.0e-14) } } test "the 2-D five-point Laplacian: a doubly degenerate pair, found as a pair" { srand(9.0) pi = 4.0 * atan(1.0) m = 16 nb = 6 vectors = random_rows(nb, m * m) values = zeros(nb) worst = lobpcg(apply_laplacian_2d, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) assert_lt(worst, 1.0e-9) # grid modes (1,1), (1,2) = (2,1), (2,2) one = 2.0 - 2.0 * cos(pi / (m + 1.0)) two = 2.0 - 2.0 * cos(2.0 * pi / (m + 1.0)) assert_near(values[0], one + one, 1.0e-14) assert_near(values[1], one + two, 1.0e-14) assert_near(values[2], one + two, 1.0e-14) assert_near(values[3], two + two, 1.0e-14) assert_lt(worst_eigen_residual(apply_laplacian_2d, vectors, values, LOBPCG_TEST_NCONV), 1.0e-9) } # SPDX-License-Identifier: MIT OR Apache-2.0 # SPDX-FileCopyrightText: 2026 The Viv authors
This is the output of viv doc lib/lobpcg.viv, unchanged. The compiler lifts every
#| (what), #|| (how) and #||| (why) line out of the code and
attaches it to the line it describes — so the line numbers here count code only.
Its glossary holds 3 variables; ask for one with viv whatis.
#[ … #]).
1
### 2
post: LOBPCG, the lowest eigenpairs of an operator you can only apply 3
agent: claude 4
5
`lobpcg(apply_h, precondition, vectors, values, max_steps, nconv, tol)` finds 6
the lowest few eigenvalues and eigenvectors of a Hermitian operator `H` that is 7
given as a function: `apply_h(x)` returns `H` applied to each row of `x`. The 8
matrix is never formed, so `H` can be a stencil, an FFT sandwich, or anything 9
else that is cheaper to apply than to store. 10
11
LOBPCG is short for locally optimal block preconditioned conjugate gradient. 12
Each step searches a small subspace for the vectors with the lowest Rayleigh 13
quotient $x^H H x / x^H x$, and the subspace is three blocks of rows: the 14
current approximations, their preconditioned residuals (the direction in which 15
the quotient falls fastest), and the step each approximation took last time. 16
The lowest eigenvalues of `H` restricted to that subspace (Ritz values) and the 17
vectors that achieve them (Ritz vectors) become the next approximations. The 18
post `lobpcg-finds-the-lowest-states` explains why this works, on a particle 19
in a box. 20
21
The arguments: 22
23
- `vectors` is `[nb, n]` complex, one starting guess per row; it comes back 24
holding the eigenvectors, orthonormal, lowest first. A real problem is a 25
complex one whose imaginary parts are zero. 26
- `values` is `[nb]`; it comes back holding the eigenvalues. 27
- `precondition(r, x)` returns an approximation to $(H - \lambda)^{-1} r$ for 28
the residual `r` of the row `x`, or `r` itself when there is nothing better: 29
`no_preconditioner` is that. 30
- Only the lowest `nconv` rows are held to `tol`, a bound on the norm of 31
$H x - \lambda x$ for a unit `x`. The rows above them are a buffer: the top 32
of a block converges slowest, because nothing above it in the block keeps it 33
apart from the rest of the spectrum. 34
- It returns the largest residual norm among those `nconv` rows. 35
36
Ask for a `tol` the arithmetic can reach. On the test problems below the 37
residual stops falling near $10^{-10}$, and an iteration held at that floor 38
with nothing left to find can come apart: on the 90 by 90 test matrix, asked 39
for $10^{-13}$, the largest residual was back above $10^{-4}$ by step 120 and 40
above 1 by step 130. The DFT engine asks for $10^{-6}$. 41
42
`lobpcg_start` and `lobpcg_step` are the same iteration a step at a time, for 43
a caller that wants to watch it. 44
45
Used with `import lobpcg`. The plane-wave DFT engine `dft/pwdft_it` uses it 46
for its bands. 47
### 48
49
LOBPCG_X = 0 50
LOBPCG_HX = 1 51
LOBPCG_P = 2 52
LOBPCG_HP = 3 53
54
LOBPCG_BLOCKS = 4 55
56
LOBPCG_LOCK_FLOOR = 1.0e-9 57
58
def row_length(a) { what How many entries each row of a block has. 59
dims = shape(a) 60
return dims[1] 61
} 62
63
def row_norms(a) { what The Euclidean norm of each row of `a`: one number per vector in a block. 64
norms = zeros(len(a)) 65
for b to len(a) { 66
v = slab(a, b) 67
norms[b] = sqrt(sum(abs(v) * abs(v))) 68
} 69
return norms 70
} 71
72
def divide_rows(a, norms) { what Divide each row of `a` by its entry in `norms`, in place. A zero row stays zero instead of becoming a row of NaN. 73
for b to len(a) { 74
if norms[b] > 1.0e-300 { 75
setslab(a, b, slab(a, b) / norms[b]) 76
} 77
} 78
} 79
80
def stacked(top, bottom) { what The rows of `top` followed by the rows of `bottom`, as one block. 81
ntop = len(top) 82
both = czeros(ntop + len(bottom), row_length(top)) 83
for b to ntop { 84
setslab(both, b, slab(top, b)) 85
} 86
for b to len(bottom) { 87
setslab(both, ntop + b, slab(bottom, b)) 88
} 89
return both 90
} 91
92
def rayleigh_quotients(x, hx) { what The Rayleigh quotient of each row, $\lambda_b = x_b^H H x_b$ for a unit row $x_b$: the best estimate of an eigenvalue that the vector alone can give. 93
lam = zeros(len(x)) 94
for b to len(x) { 95
lam[b] = sum(re(conj(slab(x, b)) * slab(hx, b))) 96
} 97
return lam 98
} 99
100
def residuals(x, hx, lam) { what The residual of each row, $r_b = H x_b - \lambda_b x_b$. It is zero exactly when $x_b$ is an eigenvector, and its norm bounds the distance from $\lambda_b$ to the nearest eigenvalue. 101
r = czeros(len(x), row_length(x)) 102
for b to len(x) { 103
setslab(r, b, slab(hx, b) - lam[b] * slab(x, b)) 104
} 105
return r 106
} 107
108
def largest_of_first(a, count) { what The largest of the first `count` entries of `a` (all of them if there are fewer). 109
biggest = 0.0 110
for b to min(count, len(a)) { 111
if a[b] > biggest { 112
biggest = a[b] 113
} 114
} 115
return biggest 116
} 117
118
def no_preconditioner(r, x) { what The identity preconditioner, for an operator with nothing better. 119
return r 120
} 121
122
def orthonormalizer(s) { what A matrix `m` whose rows make the rows of `m @ s` orthonormal and span what the rows of `s` span, minus the directions `s` barely has. how Canonical orthonormalization: eigendecompose the overlap $S = s^* s^T$ and scale each eigenvector by $1/\sqrt{w}$. Directions with $w$ under $10^{-10}$ of the largest are dropped, so `m` has at most as many rows as `s`. why Rank-revealing on purpose. Near convergence the previous step p becomes linearly dependent on the rest of the block, and keeping it poisons the Rayleigh-Ritz step: that is the classic way LOBPCG falls over. 123
ns = len(s) 124
overlap = conj(s) @ transpose(s) 125
w = zeros(ns) 126
u = czeros(ns, ns) 127
eig_herm(overlap, w, u) 128
first = 0.0 129
for a to ns { 130
if w[a] <= 1.0e-10 * w[ns - 1.0] { 131
first = a + 1.0 132
} 133
} 134
m = czeros(ns - first, ns) 135
for a to ns - first { 136
scale = 1.0 / sqrt(w[first + a]) 137
for c to ns { 138
m[a, c] = u[c, first + a] * scale 139
} 140
} 141
return m 142
} 143
144
def lowest_ritz_coefficients(s, hs, nb) { what Rayleigh-Ritz: of all unit vectors in the span of the orthonormal rows of `s`, the `nb` with the lowest Rayleigh quotients, as coefficients: row b of the result times `s` is the b-th lowest Ritz vector. how This is a dense eigenproblem, but a small one: `s` has at most three rows per wanted vector. 145
m = len(s) 146
# Hermitian in exact arithmetic, symmetrized because it is not quite in 147
# floating point: hs is carried through linear combinations rather than 148
# recomputed as H s, so s^H (hs) drifts from its adjoint by ~1e-9, which 149
# eig_herm rightly refuses to call Hermitian. 150
projected = conj(s) @ transpose(hs) 151
projected = (projected + conj(transpose(projected))) * 0.5 152
e = zeros(m) 153
c = czeros(m, m) 154
eig_herm(projected, e, c) 155
lowest = czeros(nb, m) 156
for b to nb { 157
for a to m { 158
lowest[b, a] = c[a, b] 159
} 160
} 161
return lowest 162
} 163
164
def search_directions(precondition, r, x, norms, lock) { what The search directions for one step: each row's residual, preconditioned. A row whose residual is under `lock` has converged and offers none (its row stays zero, and `orthonormalizer` drops it). This is soft locking: the row still takes part in Rayleigh-Ritz, so it keeps improving from the others' directions, but no work is spent pushing it further. 165
w = czeros(len(r), row_length(r)) 166
for b to len(r) { 167
if norms[b] >= lock { 168
setslab(w, b, precondition(slab(r, b), slab(x, b))) 169
} 170
} 171
return w 172
} 173
174
def lobpcg_start(apply_h, x) { what A LOBPCG state for the starting rows of `x`: orthonormalized, rotated into Ritz vectors of their own span, and with no previous step yet. 175
nb = len(x) 176
hx = apply_h(x) 177
m = orthonormalizer(x) 178
x = m @ x 179
hx = m @ hx 180
c = lowest_ritz_coefficients(x, hx, nb) 181
state = czeros(LOBPCG_BLOCKS, nb, row_length(x)) 182
setslab(state, LOBPCG_X, c @ x) 183
setslab(state, LOBPCG_HX, c @ hx) 184
return state 185
} 186
187
def lobpcg_step(apply_h, precondition, state, step, nconv, tol) { what One LOBPCG step on `state`, in place. Returns the largest residual norm among the lowest `nconv` rows before the step; when that is already under `tol` the state is left alone. `step` counts from 0; step 0 has no previous step p to search along. 188
x = slab(state, LOBPCG_X) 189
hx = slab(state, LOBPCG_HX) 190
nb = len(x) 191
lam = rayleigh_quotients(x, hx) 192
r = residuals(x, hx, lam) 193
norms = row_norms(r) 194
worst = largest_of_first(norms, nconv) 195
if worst < tol { 196
return worst 197
} 198
# the new directions, made orthogonal to x (what x already spans needs no 199
# searching) and then unit length. Unit length matters: a residual's norm 200
# IS the residual, so left as it is its overlap eigenvalue would be res^2, 201
# and orthonormalizer, which drops directions 1e-10 below the largest, 202
# would throw the whole correction away at res ~ 1e-5. 203
w = search_directions(precondition, r, x, norms, max(tol, LOBPCG_LOCK_FLOOR)) 204
w = w - (w @ transpose(conj(x))) @ x 205
divide_rows(w, row_norms(w)) 206
hw = apply_h(w) 207
# the subspace searched: [x; w], and from step 1 on [x; w; p] 208
s = stacked(x, w) 209
hs = stacked(hx, hw) 210
if step > 0 { 211
s = stacked(s, slab(state, LOBPCG_P)) 212
hs = stacked(hs, slab(state, LOBPCG_HP)) 213
} 214
m = orthonormalizer(s) 215
c = lowest_ritz_coefficients(m @ s, m @ hs, nb) 216
# the new x as combinations of the rows of s itself 217
k = c @ m 218
setslab(state, LOBPCG_X, k @ s) 219
setslab(state, LOBPCG_HX, k @ hs) 220
# The step p is the part of the new x that came from w and p: the same 221
# combination with the x columns struck out. 222
for b to nb { how Built directly, never as x_new - (x_new x^H) x. That subtraction cancels to the size of the step and leaves p with no relative accuracy, so (p, H p) stops being consistent with H once p is normalized, and the iteration, having converged to 1e-6, then diverges by a factor of five a step (measured, in the DFT engine at Gamma in silicon). Formed this way, H p is the same combination of rows that each satisfy h = H s, so the pair is consistent by linearity however small it gets. 223
for a to nb { 224
k[b, a] = 0.0 225
} 226
} 227
p = k @ s 228
hp = k @ hs 229
pnorms = row_norms(p) 230
divide_rows(p, pnorms) 231
divide_rows(hp, pnorms) 232
setslab(state, LOBPCG_P, p) 233
setslab(state, LOBPCG_HP, hp) 234
return worst 235
} 236
237
def lobpcg(apply_h, precondition, vectors, values, max_steps, nconv, tol) { what The lowest eigenpairs of the Hermitian operator `apply_h`, starting from the rows of `vectors`, which come back holding the eigenvectors; `values` gets the eigenvalues. At most `max_steps` steps; stops once the lowest `nconv` rows have residual norms under `tol`, and returns the largest of those. 238
state = lobpcg_start(apply_h, vectors) 239
worst = 0.0 240
for step to max_steps { 241
worst = lobpcg_step(apply_h, precondition, state, step, nconv, tol) 242
if worst < tol { 243
break 244
} 245
} 246
x = slab(state, LOBPCG_X) 247
lam = rayleigh_quotients(x, slab(state, LOBPCG_HX)) 248
for b to len(x) { 249
values[b] = lam[b] 250
setslab(vectors, b, slab(x, b)) 251
} 252
return worst 253
} 254
255
# ---------------------------------------------------------------- the checks 256
# Operators for the tests below. Each reads its size from the block it is 257
# applied to, so one def serves every size. 258
259
def apply_laplacian_1d(x) { what The second-difference matrix on n points with zero ends: 2 on the diagonal and -1 beside it, applied to each row. Its eigenvalues are known exactly: $2 - 2\cos(k\pi / (n + 1))$, k = 1 .. n. 260
n = row_length(x) 261
out = czeros(len(x), n) 262
for b to len(x) { 263
v = slab(x, b) 264
hv = czeros(n) 265
for i to n { 266
hv[i] = 2.0 * v[i] 267
if i > 0 { 268
hv[i] = hv[i] - v[i - 1] 269
} 270
if i < n - 1 { 271
hv[i] = hv[i] - v[i + 1] 272
} 273
} 274
setslab(out, b, hv) 275
} 276
return out 277
} 278
279
def apply_laplacian_2d(x) { what The five-point Laplacian on an m by m grid with zero edges, each row of `x` being a grid stored row after row. Its eigenvalues are sums of two of the 1-D ones. 280
m = round(sqrt(row_length(x))) 281
out = czeros(len(x), m * m) 282
for b to len(x) { 283
v = slab(x, b) 284
hv = czeros(m * m) 285
for i to m { 286
for j to m { 287
at = i * m + j 288
hv[at] = 4.0 * v[at] 289
if i > 0 { 290
hv[at] = hv[at] - v[at - m] 291
} 292
if i < m - 1 { 293
hv[at] = hv[at] - v[at + m] 294
} 295
if j > 0 { 296
hv[at] = hv[at] - v[at - 1] 297
} 298
if j < m - 1 { 299
hv[at] = hv[at] - v[at + 1] 300
} 301
} 302
} 303
setslab(out, b, hv) 304
} 305
return out 306
} 307
308
def unstructured_matrix(n) { what A real symmetric n by n matrix with no structure to exploit: 1 .. n down the diagonal, so the spectrum is spread out, and $\sin((i+1)(j+1))$ off it. 309
a = zeros(n, n) 310
for i to n { 311
for j to n { 312
a[i, j] = sin((i + 1.0) * (j + 1.0)) 313
} 314
a[i, i] = i + 1.0 315
} 316
return a 317
} 318
319
def apply_unstructured(x) { what `unstructured_matrix` applied to each row, as a complex operator. 320
a = unstructured_matrix(row_length(x)) 321
return x @ transpose(complex(a, a * 0.0)) 322
} 323
324
def random_rows(nb, n) { what nb random starting rows of length n. 325
x = czeros(nb, n) 326
for b to nb { 327
for i to n { 328
x[b, i] = rand() - 0.5 329
} 330
} 331
return x 332
} 333
334
LOBPCG_TEST_MAX_STEPS = 400 what The tests' budget: at most this many steps, and converged when the lowest `LOBPCG_TEST_NCONV` rows are. 335
LOBPCG_TEST_NCONV = 4 336
337
def worst_eigen_residual(apply_h, vectors, values, count) { what The largest residual norm $|H x_b - \lambda_b x_b|$ among the first `count` rows, with H applied afresh: a check on the solver that does not take its word for H x. 338
r = residuals(vectors, apply_h(vectors), values) 339
return largest_of_first(row_norms(r), count) 340
} 341
342
test "agrees with eig_sym on unstructured symmetric matrices of three sizes" { 343
srand(3.0) 344
sizes = [12.0, 40.0, 90.0] 345
for t to len(sizes) { 346
n = sizes[t] 347
nb = 5 348
vectors = random_rows(nb, n) 349
values = zeros(nb) 350
worst = lobpcg(apply_unstructured, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) 351
assert_lt(worst, 1.0e-9) 352
exact = zeros(n) 353
exact_vectors = zeros(n, n) 354
eig_sym(unstructured_matrix(n), exact, exact_vectors) 355
for b to LOBPCG_TEST_NCONV { 356
assert_near(values[b], exact[b], 1.0e-13) 357
} 358
assert_lt(worst_eigen_residual(apply_unstructured, vectors, values, LOBPCG_TEST_NCONV), 1.0e-9) 359
# the rows came back orthonormal: |x_a^H x_b| is 1 on the diagonal 360
# (where x_a^H x_a is real) and 0 off it 361
overlaps = abs(conj(vectors) @ transpose(vectors)) 362
for a to nb { 363
overlaps[a, a] = overlaps[a, a] - 1.0 364
} 365
assert_lt(max(abs(overlaps)), 1.0e-14) 366
} 367
} 368
369
test "the 1-D second difference: the lowest four, against 2 - 2 cos(k pi / (n+1))" { 370
srand(5.0) 371
pi = 4.0 * atan(1.0) 372
n = 60 373
nb = 6 374
vectors = random_rows(nb, n) 375
values = zeros(nb) 376
worst = lobpcg(apply_laplacian_1d, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) 377
assert_lt(worst, 1.0e-9) 378
for k to LOBPCG_TEST_NCONV { 379
assert_near(values[k], 2.0 - 2.0 * cos((k + 1.0) * pi / (n + 1.0)), 1.0e-14) 380
} 381
} 382
383
test "the 2-D five-point Laplacian: a doubly degenerate pair, found as a pair" { 384
srand(9.0) 385
pi = 4.0 * atan(1.0) 386
m = 16 387
nb = 6 388
vectors = random_rows(nb, m * m) 389
values = zeros(nb) 390
worst = lobpcg(apply_laplacian_2d, no_preconditioner, vectors, values, LOBPCG_TEST_MAX_STEPS, LOBPCG_TEST_NCONV, 1.0e-9) 391
assert_lt(worst, 1.0e-9) 392
# grid modes (1,1), (1,2) = (2,1), (2,2) 393
one = 2.0 - 2.0 * cos(pi / (m + 1.0)) 394
two = 2.0 - 2.0 * cos(2.0 * pi / (m + 1.0)) 395
assert_near(values[0], one + one, 1.0e-14) 396
assert_near(values[1], one + two, 1.0e-14) 397
assert_near(values[2], one + two, 1.0e-14) 398
assert_near(values[3], two + two, 1.0e-14) 399
assert_lt(worst_eigen_residual(apply_laplacian_2d, vectors, values, LOBPCG_TEST_NCONV), 1.0e-9) 400
} 401
402
# SPDX-License-Identifier: MIT OR Apache-2.0 403
# SPDX-FileCopyrightText: 2026 The Viv authors