Visualizing Convergence of Krylov Eigensolvers

April 14, 2025

I show a simple illustration of eigensolvers converging on eigenvectors for a very simple case: a diagonal matrix. While the diagonal matrix is a trivial example because we can analytically compute its eigenvalues (diagonal entries) and eigenvectors (columns of the identity matrix), it captures the exact-arithmetic polynomial approximation problem for a symmetric matrix. The fact that we can decompose any symmetric matrix \[ A = V \Lambda V^T \] with orthonormal \(V \) and diagonal \( \Lambda \) means that any polynomial of \(A\) can be fully described by that same polynomial on \( \Lambda \), and since the defining feature of Krylov methods is the use of polynomials this means we can reproduce its spectral convergence behavior with a diagonal matrix and a correspondingly rotated starting vector. Finite-precision behavior and the cost of applying \(A\) are not preserved by this reduction, so the diagonal experiment isolates convergence rather than implementation difficulty.

The Krylov Methods

I show three simple eigensolver prototypes.

  1. Subspace iteration
  2. Subspace iteration with Ritz vectors
  3. Restarted Arnoldi iteration

Subspace iteration

This algorithm starts with an orthonormal basis \(V\), applies \( A \) to it, QR factorizes the result, and repeats the process. This is usually called simultaneous iteration or subspace iteration. It uses a QR factorization internally, but it is not the classical QR eigenvalue algorithm that repeatedly factorizes and updates the matrix itself.

import numpy as np
import scipy.linalg as la
from typing import Callable, Optional

def power(
    A: np.ndarray, 
    V: np.ndarray, 
    maxiter: int = 20, 
    callback: Optional[Callable[[np.ndarray], None]] = None
) -> np.ndarray:
    """
    Power iteration-like method for refining an orthonormal basis.

    This algorithm starts with an initial orthonormal matrix `V`, applies `A` to it,
    then performs a QR factorization, repeating this process for `maxiter` iterations.

    Args:
        A (np.ndarray): The matrix to be applied iteratively.
        V (np.ndarray): Initial orthonormal basis.
        maxiter (int, optional): Number of iterations to run. Default is 20.
        callback (Optional[Callable[[np.ndarray], None]], optional): 
            A function that is called with `V` at each iteration. Default is None.

    Returns:
        np.ndarray: The final orthonormal matrix after iterations.
    """
    
    # Ensure V is an orthonormal basis initially
    V, _ = la.qr(V, mode="economic")
    
    # Call the callback with the initial V if provided
    if callback:
        callback(V)
    
    # Iterative process
    for _ in range(maxiter):
        AV = A @ V  # Apply matrix A to V
        V, _ = la.qr(AV, mode="economic")  # Re-orthonormalize using QR factorization
        
        # Call the callback function at each iteration if provided
        if callback:
            callback(V)
    
    return V


Subspace iteration with Ritz vectors

By doing a little extra work on each iteration of the QR iteration we can compute Ritz vectors which provide eigenvector approximations from the subspace represented by \( V \)

import numpy as np
import scipy.linalg as la
from typing import Callable, Optional

def power_ritz(
    A: np.ndarray, 
    V: np.ndarray, 
    maxiter: int = 20, 
    callback: Optional[Callable[[np.ndarray], None]] = None
) -> np.ndarray:
    """
    Power iteration with Ritz vector refinement.

    This algorithm iteratively applies `A` to an orthonormal basis `V`, 
    performs QR factorization, and refines the basis using the eigenvectors 
    of the projected matrix `V^T A V`.

    Args:
        A (np.ndarray): The matrix to be applied iteratively.
        V (np.ndarray): Initial orthonormal basis.
        maxiter (int, optional): Number of iterations to run. Default is 20.
        callback (Optional[Callable[[np.ndarray], None]], optional): 
            A function that is called with `V` at each iteration. Default is None.

    Returns:
        np.ndarray: The final orthonormal matrix after iterations.
    """
    
    # Ensure V is an orthonormal basis initially
    V, _ = la.qr(V, mode="economic")

    # Call the callback with the initial V if provided
    if callback:
        callback(V)
    
    # Iterative process
    for _ in range(maxiter):
        AV = A @ V  # Apply matrix A to V
        V, _ = la.qr(AV, mode="economic")  # Re-orthonormalize using QR factorization
        
        # Compute the Rayleigh quotient for the *new* basis: VTAV = V^T A V
        VTAV = V.T @ A @ V
        
        # Solve for eigenvalues and eigenvectors of VTAV
        eigenvalues, W = la.eigh(VTAV)
        
        # Transform V using the Ritz vectors
        V = V @ W
        
        # Call the callback function at each iteration if provided
        if callback:
            callback(V)
    
    return V

Restarted Arnoldi iteration

The next algorithm breaks from the previous algorithms by not driving all vectors in \( V\) at the same time. This leads to economy of evaluations of \(Ax\) but creates a data dependency in between every such evaluation, making it impossible to batch them. The payoff is high convergence rates.

This algorithm also orthogonalizes in between steps but does so through the Arnoldi factorization instead of the QR factorization: \(A V_k = V_{k+1}\bar H_k\), where \( V_{k+1} \) is orthonormal and \( \bar H_k \) is upper Hessenberg.

import numpy as np
import scipy.linalg as la
from typing import Callable, Optional

def arnoldi(
    A: np.ndarray, 
    v: np.ndarray, 
    k: int, 
    maxiter: int, 
    callback: Optional[Callable[[np.ndarray], None]] = None
) -> None:
    """
    Arnoldi iteration for Krylov subspace approximation.

    This algorithm constructs an orthonormal basis of the Krylov subspace using 
    the Arnoldi process and computes the Hessenberg matrix `H`. It refines the 
    basis using eigenvectors of `H` at each iteration.

    Args:
        A (np.ndarray): The matrix for which the Krylov subspace is constructed.
        v (np.ndarray): The initial vector for Krylov subspace iteration.
        k (int): The number of Arnoldi iterations to perform per cycle.
        maxiter (int): The total number of iterations.
        callback (Optional[Callable[[np.ndarray], None]], optional): 
            A function that receives the orthonormal basis at each iteration. Default is None.
    
    Returns:
        None: The function does not return a value but may call `callback` at each step.
    """

    # Utility functions for norm and dot product
    norm = np.linalg.norm
    
    m = v.size  # Dimension of the vector space
    V = np.zeros((m, k + 1))  # Orthonormal basis matrix
    H = np.zeros((k + 1, k))  # Upper Hessenberg matrix

    # Normalize initial vector
    V[:, 0] = v / norm(v)

    # Call the callback function with the initial basis if provided
    if callback:
        callback(V[:, :k])

    nevals = 0  # Number of evaluations
    while nevals < maxiter:
        # Re-normalize the starting vector at the beginning of each cycle
        V[:, 0] = v / norm(v)

        for j in range(k):
            w = A @ V[:, j]  # Apply A to the current basis vector
            h = V[:, :j + 1].T @ w  # Project w onto existing basis in V
            
            # Compute residual f after projection
            f = w - V[:, :j + 1] @ h
            # "DGKS" correction
            s = V[:, :j + 1].T @ f
            f -= V[:, :j + 1] @ s
            h += s
            
            beta = norm(f)  # Compute norm of f
            H[:j + 1, j] = h  # Store coefficients in H
            H[j + 1, j] = beta  # Subdiagonal value
            
            if beta == 0.0:
                raise RuntimeError("Arnoldi breakdown: invariant subspace found")
            V[:, j + 1] = f / beta  # Normalize and store new basis vector

        nevals += k  # This cycle used k matrix-vector products

        # Compute eigenvalues and eigenvectors of the Hessenberg matrix
        eigenvalues, W = la.eigh(H[:k, :k])  # Valid here because A is symmetric

        # Call the callback function with the transformed basis if provided
        if callback:
            callback(V[:, :k] @ W)

        # Update the starting vector for the next cycle
        v = V[:, :k] @ W @ np.ones(k)

The matrix and results

The matrix I use just contains positive real diagonal values starting at \(r>0\) and ending at \(1\).

$$ D = \begin{bmatrix} r & 0 & 0 & \cdots & 0 \\ 0 & r + \frac{1-r}{n-1} & 0 & \cdots & 0 \\ 0 & 0 & r + 2\frac{1-r}{n-1} & \cdots & 0 \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & \cdots & 1 \end{bmatrix} $$

For visualization purposes I use a small matrix with just 32 rows, and I ask each eigensolver to compute \(k=5\) eigenvectors.

The Arnoldi prototype was initialized with a random vector with uniformly distributed entries; the subspace iterations require a random matrix with \(k\) columns, which is orthonormalized before the first step.

The original plots label a full update of the displayed basis as an “iteration,” but that is not a fair unit of work: one subspace-iteration step applies \(A\) to \(k\) vectors and one Arnoldi cycle also performs \(k\) sequential matrix-vector products. Matrix-vector product count, together with orthogonalization cost, is the cleaner comparison.

Finally to visualize I show the state of each iteration for the computed eigenvectors. Since the matrix is diagonal we know the eigenvectors are just columns of the identity matrix, so we know that the eigenvectors should converge to just one nonzero entry. None of these algorithms attempt any kind of shift, deflation, or careful restarting that would allow us to select specific eigenvalues so the only thing they will do is converge to some eigenvectors

Iteration Computed Eigenvectors
0
25
50
100
200

Observations

Subspace iteration, as expected, converges to the invariant subspace associated with the eigenvalues of largest magnitude. The original Ritz-vector version appeared to have trouble converging to individual identity columns because its projected matrix mixed the newly computed basis \(V\) with the stale product AV from the previous basis. The corrected code recomputes V.T @ A @ V. Ritz rotation does not change the converged subspace, but it should turn a sufficiently accurate invariant subspace into individual eigenvector approximations when the selected eigenvalues are distinct.

The most interesting result here for me was the Arnoldi iteration, which produced Ritz vectors from a wider part of the spectrum rather than only the largest eigenvalues. This prototype uses a simple explicit restart by summing the current Ritz vectors, so the pictures show that particular heuristic rather than a standard implicitly restarted Arnoldi method.