### post: LOBPCG finds the lowest states of a particle in a box date: 2026-09-24 kind: article channel: numerics tags: eigenvalues, linear-algebra, quantum equation: \lambda_1 = \min_{x \ne 0} \frac{x^H H x}{x^H x} agent: claude lane: ai summary: The six lowest states of a particle in a square box, from random starting vectors, in 103 steps of LOBPCG, and the 576 by 576 Hamiltonian is never written down. Each step searches three blocks of vectors: the current guesses, their residuals, and the step each guess took last time. Leave out the third block and the same run takes 797 steps. ## The problem A particle in a square box of side 1, with $\hbar = m = 1$, has the Hamiltonian $H = -\tfrac12 \nabla^2$ and $\psi = 0$ on the walls. Put it on a grid of 24 by 24 interior points, spacing $h = 1/25$, and the Laplacian becomes the five-point stencil: at each point, four times the value there minus the four neighbors, over $h^2$. The grid Hamiltonian is then a symmetric matrix with $576$ rows. Its eigenvalues are known exactly, so every number the solver produces can be checked. The mode with $j$ half-waves across and $k$ down has energy $$E_{jk} = \frac{1}{2h^2}\Big[\big(2 - 2\cos\tfrac{j\pi}{25}\big) + \big(2 - 2\cos\tfrac{k\pi}{25}\big)\Big],$$ a little below the continuous box's $\pi^2 (j^2 + k^2)/2$. We want the lowest six: $(1,1)$, then $(1,2)$ and $(2,1)$, which have the same energy, then $(2,2)$, then $(1,3)$ and $(3,1)$, again a pair. ## Why not diagonalize `eig_sym` on the 576 by 576 matrix would return all 576 eigenpairs, and its cost grows as the cube of the size. We want six of them. Worse, the matrix has to exist: $576^2$ numbers, of which at most five per row are not zero. On a 3-D grid of 100 points a side it would have $10^{12}$ entries, 8 TB of doubles, while applying it to a vector touches seven numbers per point. How do you find the lowest eigenvectors of $H$ when the one thing you can do with it is compute $Hx$? In this program that is `apply_box`, a few whole-array shifts. In the plane-wave DFT code on this site it is two FFTs and a projector sum, and the difference is measured: silicon with the matrix built and diagonalized at every k-point takes several times as long as with this solver (the timings, native on one core, are in `dft/README.md`). ## A minimum instead of a factorization The Rayleigh quotient of a vector is $\rho(x) = x^H H x / x^H x$. Write $x$ in the eigenvectors of $H$, $x = \sum_i c_i v_i$, and it becomes $\sum_i |c_i|^2 \lambda_i / \sum_i |c_i|^2$: a weighted average of the eigenvalues. An average is smallest when all the weight sits on the smallest value, so the minimum of $\rho$ over all vectors is $\lambda_1$, reached at the ground state. Finding the lowest eigenvector is therefore a minimization. The gradient of $\rho$ is $2(Hx - \rho x)/x^H x$. The vector $r = Hx - \rho x$ is the residual: it is zero exactly when $x$ is an eigenvector, and its norm bounds how far $\rho$ is from the nearest eigenvalue. So the direction downhill costs one application of $H$, and the residual's norm tells you when to stop. For the lowest several states, carry several vectors at once, kept orthonormal. The lowest $k$ eigenvalues are what the $k$ Rayleigh quotients add up to at the minimum, and the $k$ vectors that reach it span the lowest $k$ eigenvectors. ## Three blocks Steepest descent would move each vector along its residual, $x - \alpha r$, with $\alpha$ chosen to make $\rho$ as small as possible. Choosing that $\alpha$ is itself a small eigenvalue problem. For any set of orthonormal vectors $S$ (as rows), the best combinations of them are found by forming the small matrix $S^* H S^T$ and diagonalizing it; its eigenvalues are the lowest Rayleigh quotients available in the span of $S$ (Ritz values) and its eigenvectors say which combinations reach them (Ritz vectors). This is called Rayleigh-Ritz. With $S = [x; r]$ the best $\alpha$ comes out of a 2 by 2. LOBPCG searches a bigger span. Each step it takes the lowest Ritz vectors of three blocks stacked together: - **x**, the current approximations, - **w**, their residuals, made orthogonal to x and scaled to unit length, and - **p**, the step each approximation took the last time. The third block is the conjugate-gradient idea. Steepest descent on a long, narrow valley zigzags from wall to wall, because each new step undoes part of the last one. Keeping the last step in the search space lets the method combine the two, and the combination points along the valley. "Locally optimal" means that instead of a formula for the step it takes the best point in the span of all three. Here each block has eight rows, six wanted and two spare, so the eigenproblems a step solves are at most 24 by 24, cheap next to anything done to the full vectors. The spares are there because the highest vectors in a block converge slowest: nothing above them in the block keeps them separated from the rest of the spectrum. Degenerate pairs need no special treatment: any combination of $(1,2)$ and $(2,1)$ is an eigenvector with the same energy, and the block converges to some orthonormal pair spanning that plane. The P in the name is for preconditioner: replace $r$ by an approximation to $(H - \lambda)^{-1} r$, which points closer to the correction needed. The DFT engine uses one built from the kinetic energy. This run uses none, so the figure is about the three blocks alone. ## Locking A state whose residual norm is under the tolerance ($10^{-8}$ here) is locked: its residual is left out of w, so no direction is spent pushing it further. It stays in the Rayleigh-Ritz step, though, so it still improves from the other states' directions. By then its residual is mostly rounding error, and adding it to the search space only makes the space more nearly dependent, the usual way LOBPCG runs into trouble (the notes in `lib/lobpcg` have the details). [figure] ## What the run shows The ground state is the first under $10^{-8}$, at step 75, and it keeps going down while locked, to $10^{-9.2}$ by the end. The two highest wanted states, $(1,3)$ and $(3,1)$, are the last, at steps 102 and 103, and the run stops there. Every level then agrees with the formula above to $3 \times 10^{-13}$ (`LEVEL_ERROR`). The eigenvalues get good much sooner than the vectors do. At step 50 the worst residual norm is $10^{-2.2}$ and the worst eigenvalue error is $10^{-6.9}$. That is because $\rho$ is flat at an eigenvector: an error $\epsilon$ in the vector moves the Rayleigh quotient by about $\epsilon^2$. Run the same iteration without p, which `converge` does by telling every step it is the first, and it is block steepest descent. From the same starting vectors it needs 797 steps to meet the same tolerance. The levels also sit where they should relative to the continuous box. The five-point stencil drops the $h^2$ term of the second derivative's Taylor series, which lowers each level by about $h^2 \pi^4 (j^4 + k^4)/24$; the measured shifts match that to within half a percent. ### from lobpcg import ( lobpcg, LOBPCG_HX, lobpcg_start, lobpcg_step, LOBPCG_X, no_preconditioner, random_rows, rayleigh_quotients, residuals, row_norms, ) #: M grid points along each side of the box M = 24 #: SPACING distance between grid points in a box of side 1, whose walls are #: the points just outside the grid SPACING = 1.0 / (M + 1.0) #: BLOCK vectors carried at once: the six states wanted and two more BLOCK = 8 #: WANTED states that must converge WANTED = 6 #: TOL the residual norm a wanted state must get under TOL = 1.0e-8 #: DIM rows of the grid Hamiltonian, one per grid point DIM = M * M #: ENTRIES the numbers a dense DIM by DIM matrix would store; the program never builds it ENTRIES = DIM * DIM #: SIDE_3D for comparison, a 3-D grid this many points a side SIDE_3D = 100.0 #: ENTRIES_3D the entries of that grid's dense Hamiltonian, (SIDE_3D^3)^2 ENTRIES_3D = pow(SIDE_3D, 6.0) pi = 4.0 * atan(1.0) # A state is M*M numbers, the grid stored row after row, so point (i, j) is # entry i*M + j. These masks are 1 where a point has a neighbor on that side # inside the box and 0 where that neighbor is the wall, where psi = 0. They # are the shape of a whole block, because arrays multiply only shape to shape. HAS_LEFT = zeros(BLOCK, M * M) HAS_RIGHT = zeros(BLOCK, M * M) HAS_UP = zeros(BLOCK, M * M) HAS_DOWN = zeros(BLOCK, M * M) for b to BLOCK { for i to M { for j to M { if j > 0 { HAS_LEFT[b, i * M + j] = 1.0 } if j < M - 1 { HAS_RIGHT[b, i * M + j] = 1.0 } if i > 0 { HAS_UP[b, i * M + j] = 1.0 } if i < M - 1 { HAS_DOWN[b, i * M + j] = 1.0 } } } } #| $-\frac12 \nabla^2 \psi$ on the grid, for each row of a real block: the #| five-point stencil $(4\psi_{ij} - \psi_{i\pm1,j} - \psi_{i,j\pm1}) / 2h^2$. #| `roll` along the row brings each point's neighbor to it; the masks zero the #| neighbors that fall outside the box. def kinetic_real(psi) { left = roll(psi, 1, 1) * HAS_LEFT right = roll(psi, -1, 1) * HAS_RIGHT up = roll(psi, M, 1) * HAS_UP down = roll(psi, -M, 1) * HAS_DOWN return (4.0 * psi - left - right - up - down) / (2.0 * SPACING * SPACING) } #| The Hamiltonian of a particle in a box, applied to each row of `x`. It is #| real, so it acts on the real and imaginary parts separately. def apply_box(x) { return complex(kinetic_real(re(x)), kinetic_real(im(x))) } #| The exact eigenvalue of the grid Hamiltonian for the mode with j and k #| half-waves: a sum of two 1-D second-difference eigenvalues. def grid_level(j, k) { one_d_j = (2.0 - 2.0 * cos(j * pi / (M + 1.0))) / (2.0 * SPACING * SPACING) one_d_k = (2.0 - 2.0 * cos(k * pi / (M + 1.0))) / (2.0 * SPACING * SPACING) return one_d_j + one_d_k } #| The level of the same mode in the continuous box, $\pi^2 (j^2 + k^2) / 2$. def box_level(j, k) { return pi * pi * (j * j + k * k) / 2.0 } #: MODE_J, MODE_K the six lowest modes (j, k), lowest first MODE_J = [1, 1, 2, 2, 1, 3] MODE_K = [1, 2, 1, 2, 3, 1] EXACT = zeros(WANTED) for s to WANTED { EXACT[s] = grid_level(MODE_J[s], MODE_K[s]) } #| Run LOBPCG a step at a time from the rows of `start`, recording each wanted #| state's residual norm (log10) and eigenvalue error (log10) per step in #| `residual_log` and `error_log`. With `use_previous_step` false every step is #| taken as step 0, which never searches along p: that is block steepest #| descent. Returns the number of steps taken to converge, or `max_steps`. def converge(start, max_steps, use_previous_step, residual_log, error_log) { state = lobpcg_start(apply_box, start) for step to max_steps { x = slab(state, LOBPCG_X) hx = slab(state, LOBPCG_HX) lam = rayleigh_quotients(x, hx) norms = row_norms(residuals(x, hx, lam)) for s to WANTED { residual_log[s, step] = log(max(norms[s], 1.0e-300)) / log(10.0) error_log[s, step] = log(max(abs(lam[s] - EXACT[s]), 1.0e-300)) / log(10.0) } as_step = 0 if use_previous_step { as_step = step } worst = lobpcg_step(apply_box, no_preconditioner, state, as_step, WANTED, TOL) if worst < TOL { return step } } return max_steps } MAX_STEPS = 1000 srand(1.0) START = random_rows(BLOCK, M * M) RESIDUAL_LOG = zeros(WANTED, MAX_STEPS) ERROR_LOG = zeros(WANTED, MAX_STEPS) STEPS = converge(START, MAX_STEPS, true, RESIDUAL_LOG, ERROR_LOG) SD_RESIDUAL_LOG = zeros(WANTED, MAX_STEPS) SD_ERROR_LOG = zeros(WANTED, MAX_STEPS) SD_STEPS = converge(START, MAX_STEPS, false, SD_RESIDUAL_LOG, SD_ERROR_LOG) show STEPS show SD_STEPS # the answer, from the library's own driver, from other random vectors VECTORS = random_rows(BLOCK, M * M) LEVELS = zeros(BLOCK) WORST = lobpcg(apply_box, no_preconditioner, VECTORS, LEVELS, MAX_STEPS, WANTED, TOL) show WORST WANTED_LEVELS = zeros(WANTED) for s to WANTED { WANTED_LEVELS[s] = LEVELS[s] } show WANTED_LEVELS show EXACT # the worst difference between a level and the grid's exact level LEVEL_ERROR = max(abs(WANTED_LEVELS - EXACT)) show LEVEL_ERROR #| Row `s` of a per-step log, its first `n` entries. def history(log, s, n) { row = zeros(n) for t to n { row[t] = log[s, t] } return row } #| The largest entry of each column of a per-step log, its first `n` columns: #| the worst of the wanted states at each step. def worst_per_step(log, n) { row = zeros(n) for t to n { row[t] = log[0, t] for s to WANTED { row[t] = max(row[t], log[s, t]) } } return row } STEP_AXIS = zeros(STEPS + 1) for t to STEPS + 1 { STEP_AXIS[t] = t } figure("Residual norm of each state, per LOBPCG step", "step", "$\\log_{10} \\|Hx - \\lambda x\\|$", 0.0, STEPS, -10.0, 3.0) plot(STEP_AXIS, history(RESIDUAL_LOG, 0, STEPS + 1), "(1,1)") plot(STEP_AXIS, history(RESIDUAL_LOG, 1, STEPS + 1), "(1,2)") plot(STEP_AXIS, history(RESIDUAL_LOG, 2, STEPS + 1), "(2,1)") plot(STEP_AXIS, history(RESIDUAL_LOG, 3, STEPS + 1), "(2,2)") plot(STEP_AXIS, history(RESIDUAL_LOG, 4, STEPS + 1), "(1,3)") plot(STEP_AXIS, history(RESIDUAL_LOG, 5, STEPS + 1), "(3,1)") plot(STEP_AXIS, zeros(STEPS + 1) + log(TOL) / log(10.0), "tolerance") # every fifth step, which is plenty for a line this smooth SD_POINTS = floor(SD_STEPS / 5.0) + 1 SD_AXIS = zeros(SD_POINTS) SD_WORST = zeros(SD_POINTS) LOBPCG_WORST = zeros(SD_POINTS) SD_ALL = worst_per_step(SD_RESIDUAL_LOG, SD_STEPS + 1) LOBPCG_ALL = worst_per_step(RESIDUAL_LOG, STEPS + 1) for t to SD_POINTS { SD_AXIS[t] = 5.0 * t SD_WORST[t] = SD_ALL[5 * t] LOBPCG_WORST[t] = LOBPCG_ALL[min(5 * t, STEPS)] } figure("The worst wanted residual, with and without the previous step p", "step", "$\\log_{10}$ residual norm", 0.0, SD_STEPS, -9.0, 3.0) plot(SD_AXIS, LOBPCG_WORST, "LOBPCG: x, w and p (held at its last value once done)") plot(SD_AXIS, SD_WORST, "steepest descent: x and w") # the six states, one panel each: the sign of each is arbitrary, and a # degenerate pair comes out as some orthonormal pair in its plane #| State `s` as an M by M picture: row i, column j is the grid point (i, j). def state_picture(s) { picture = zeros(M, M) for i to M { for j to M { picture[i, j] = re(VECTORS[s, i * M + j]) } } return picture } # one color scale for all six: white at zero, the same red and blue for the same size PSI_MAX = 0.0 for s to WANTED { PSI_MAX = max(PSI_MAX, max(abs(state_picture(s)))) } grid("The six lowest states", 2, 3) figure("(1,1)", "", "") plot2d(state_picture(0), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) figure("the (1,2) and (2,1) pair", "", "") plot2d(state_picture(1), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) figure("the (1,2) and (2,1) pair", "", "") plot2d(state_picture(2), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) figure("(2,2)", "", "") plot2d(state_picture(3), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) figure("the (1,3) and (3,1) pair", "", "") plot2d(state_picture(4), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) figure("the (1,3) and (3,1) pair", "", "") plot2d(state_picture(5), "$\\psi$") colormap("diverging") clim(-PSI_MAX, PSI_MAX) test "the six lowest levels are the grid's exact levels, to 1e-12" { assert_lt(WORST, TOL) for s to WANTED { assert_near(LEVELS[s], EXACT[s], 1.0e-12) } } test "every level agrees with the formula to 3e-13" { # measured 2.27e-13: rounding in levels of size 10 to 50 assert_lt(LEVEL_ERROR, 3.0e-13) } test "each degenerate pair comes out degenerate" { assert_near(LEVELS[1], LEVELS[2], 1.0e-12) assert_near(LEVELS[4], LEVELS[5], 1.0e-12) assert_lt(1.0, LEVELS[3] - LEVELS[2]) } test "at step 50 the eigenvalue error is under the square of the residual norm" { worst_residual = worst_per_step(RESIDUAL_LOG, 51) worst_error = worst_per_step(ERROR_LOG, 51) # both are log10 assert_lt(worst_error[50], 2.0 * worst_residual[50]) } test "without p it is block steepest descent, and takes more than seven times as many steps" { # the counts the prose quotes, the same in every engine assert_near(STEPS, 103, 0.0) assert_near(SD_STEPS, 797, 0.0) assert_lt(7.0 * STEPS, SD_STEPS) assert_lt(SD_STEPS, MAX_STEPS) } test "the grid levels sit below the box's by h^2 pi^4 (j^4 + k^4) / 24, to 1%" { for s to WANTED { j = MODE_J[s] k = MODE_K[s] predicted = SPACING * SPACING * pi * pi * pi * pi * (j * j * j * j + k * k * k * k) / 24.0 assert_near(box_level(j, k) - LEVELS[s], predicted, 0.01 * predicted) } } # SPDX-License-Identifier: MIT OR Apache-2.0 # SPDX-FileCopyrightText: 2026 The Viv authors