Gauss-Seidel is faster than Jacobi. Or is it?

Hej,

It’s been a long time! Hope everyone enjoyed their summer break. It has been a long time also since I had time to write a new blog post so here it comes: Gauss-Seidel is faster than Jacobi. Or is it?

It is a continuation of my previous post (almost a year ago already) on the Jacobi method in Fortran. I wanted to keep things at a high level but still explain why, on modern CPUs, the Jacobi method could actually be faster than Gauss-Seidel despite the maths saying its convergence rate is worse. I guess most of the material will be pretty obvious for our members, but who knows, it may be useful for students :slight_smile:

13 Likes

I would find it interesting to compare your Jacobi implementation used as a pre-conditioner for GMRES or CG with ILU with parallelization in mind. Jacobi is still attractive as a pre-conditioner in some applications due to the fact that it can be parallelized when other (faster converging) methods can’t.

That is part of the series i have in mind :slight_smile:

Eventually, I want to address red/black Gauss-Seidel, Chebyshev-Jacobi, Conjugate gradient, Krylov methods, and preconditioning. While I’d love to be able to stick to 1 post/month, I ain’t sure I’ll manage. Too many other things to do and too little time.

2 Likes

Great work! One thing I was wondering about is the roughly 10× difference in cost per iteration between the two methods.

If vectorization is the main reason for the difference, then for double precision and AVX-512 I would have expected something closer to an 8× improvement in the ideal case. Could the remaining difference perhaps be related to the loop-carried dependency in lexicographic Gauss–Seidel? Besides preventing straightforward vectorization, this dependency might also limit instruction-level parallelism and make the loop more sensitive to instruction latency, whereas Jacobi has many independent updates that the CPU can overlap.

It might be interesting to repeat the test with vectorization explicitly disabled for Jacobi (novec or the corresponding compiler option). Comparing optimized Jacobi with and without vectorization could help separate the SIMD contribution from other effects. If Jacobi remains noticeably faster than Gauss–Seidel in the non-vectorized case, that might give some indication of how important these other effects are.

Also, I think there is a small typo in the Gauss–Seidel formula in the post: in the last term on the RHS the superscript should be (t+1), rather than (t).

Typo fixed. Thanks a lot.

Yes, there are many things that could be explored using the Jacobi and Gauss-Seidel kernels, both mathematically and code/compiler-wise. I came to realize that, even though they can be massively outperformed by other techniques and may seem outdated, these two kernels are simple enough that any undergraduate student can understand the gist of it. Yet, despite this mathematical (relative) simplicity, they offer a superb platform to explore good practice in scientific computing (and programming in general I guess) as well as to understand subtle concepts such as vectorization and how a compiler may leverage fundamental properties to make the code run faster.

I do have in mind a set of more computer science/compiler oriented posts in the future, but I ain’t quite there yet.

Very interesting post!

Since you use Poisson equation in the post, could I ask if these basic iterative methods (like Jacobi, Gauss-Seidel, SOR) work well on this problem? Or Krylov-based methods are way better than these basic iterative methods for Poisson equation?

Very roughly, for the standard 2D Poisson problem on an N \times N grid, Jacobi needs O(N^2) iterations for a fixed error reduction, while Gauss-Seidel has the same scaling but usually takes about half as many iterations. Optimal parameter SOR and Chebyshev iteration are O(N), and CG has the same worst-case scaling.

In practice, CG is often noticeably better than that bound, since its convergence depends on the actual eigenvalue distribution and on which spectral components are present in the error.

It is also worth mentioning multigrid methods, whose iteration count can be essentially independent of the problem size.

2 Likes

Let’s be specific here: the Poisson equation I consider as test case is the 2D Poisson equation on the unit square (or rectangle) discretized with second-order accurate central finite differences on a uniform mesh.

Do basic iterative methods work well? (Jacobi, Gauss-Seidel, SOR) – They work, for sure. Do they work well ? It kind of depends. I would never use Jacobi or Gauss-Seidel as actual solvers outside of a pedagogical objective. While they are very simple to implement, they are simply too slow. On the blog post, you can see that for a 512² grid, the fastest Jacobi takes 16 seconds to solve the system. Once. This is not practical in any way if you have to solve this equation repeatedly like in an incompressible Navier-Stokes simulation.

SOR, that is a different story. For this particular discretized equation, I think it has one of the best trade-off between code complexity and computational performance. Code-wise, it is basically a minor variation of Gauss-Seidel, so very easy to implement. Performance-wise, it is way more efficient. Even more so for this particular test case since you actually have a closed-form expression for the optimal relaxation weight. There are some caveats though:

  • Just like Gauss-Seidel, SOR suffers from the same loop-carried dependency. It’ll thus be harder for the compiler to optimize the code. But at the same time, the method requires far fewer iteration to converge. So, trade off again.
  • If you run a 3D case (or even a big 2D case) and have to implement the linear solver yourself, SOR can be a pain to parallelize.

If you want to stay in Jacobi-land in terms of solver complexity, but still have pretty good performances and relatively straightforward parallelization, Chebyshev-Jacobi is in my opinion your best alternative. You get convergence rate somewhat similar to SOR but retain the simplicity of Jacobi. And it might actually be more computationally efficient than SOR (even though it requires more iterations) precisely because the compiler can vectorize the code easily. Another important feature is that, except for periodic computation of the norm of the correction vector, no inner product is required and thus no all_reduce in parallel which would force a synchronization of the different processes.

How about Krylov methods ? (Conjugate Gradient, GMRES) – I’ll consider only CG since the test case is a symmetric positive definite operator. You’ll have to be slightly more careful when implementing it, but it ain’t that complicated. And it’ll converge a lot faster than Jacobi or Gauss-Seidel. It also turns out to be very closely related to the Chebyshev-Jacobi method (the two can essentially be derived in the same way).

I’ve just ran a quick test case with LightKrylov, and basically it converges in roughly 1000 iterations. That is 1000 matrix-vector products, compared to the 74 000 / 138 000 needed for Gauss-Seidel/Jacobi. So even if a single iteration may be slightly more costly (in particular because it involves a handful of inner products), time-to-solution is well under a second. With a block-Jacobi preconditionner, the number of iterations drops around 500, so even faster. And in general, having a good preconditionner is key for maximum computational performance but is very case-dependent.

@rsci and @loiseaujc Thanks a lot!

Maybe because of my discipline, I have never heard about Chebyshev iteration or Chebyshev-Jacobi. Are there good references for this method?

The reason I asked for the comparison between stationary vs Krylov methods is indeed the solver complexity. Krylov-based methods are way faster, but it seems they must work with good preconditioners, which is hard to implement or maybe I do not fully understand how they work.

Does Chebyshev-Jocobi have performance comparable to CG? If so, I might want to try it first.

Glad you asked ! I am actually in the process of writing a short paper where I re-derive the Chebyshev-Jacobi method from a modern convex optimization point of view. I think adopting this point of view is nice as it enables making link with popular techniques being used elsewhere (looking at you the ML community).

So here is the very condensed version. I’ll skip all of the proofs for clarity. Suppose you want to solve Pz = q where P is a symmetric positive definite matrix. It turns out that this system of equation is nothing but the optimality condition of the following strictly convex unconstrained quadratic program

\mathrm{minimize} \quad f(z) \equiv \dfrac12 z^\top P z - z^\top q

Why is it the optimality condition ? Because the minimizer is a point z_\star where f(z_\star) is minimal and thus its gradient \nabla f(z_\star) \equiv P z_\star - q = 0. Hence P z_\star = q, i.e. the problem we started with.

How to solve this problem? – Most optimization solvers use some sort of descent method incrementally improving an initial guess until some convergence criterion is reached. A large class of these descent methods can be written as

z_{t+1} = z_t + \sum_{i=0}^{t-1} \alpha_i^{(t)} \left( z_{i+1} - z_i \right) + \alpha_t^{(t)} \nabla f(z_t).

Here, z_t is our estimate of the solution at iteration t. The term \sum_{i=0}^{t-1} \alpha_i^{(t)} ( z_{i+1} - z_i) essentially is a momentum term, and the last term \alpha_t^{(t)} \nabla f(z_t) is just the descent direction given by the gradient. What differentiate most optimizers is the choice of the non-stationary weights \left\{ \alpha_i^{(t)} \right\}_{i=0, \cdots, t}. For instance, standard gradient descent can be recovered if you set \alpha_i^{(t)} = 0 \forall i = 0, \cdots, t-1 and \alpha_t^{(t)} = -\eta where \eta is the so-called step size. Gradient descent with optimal step size can be obtained by letting \alpha_t^{(t)} = \mathrm{argmin} \ f \left( x_t + \alpha_t^{(t)} \nabla f (x_t) \right), and Polyak’s heavy-ball is yet another example.

How does the Jacobi method fit in this framework ? – Suppose you want to solve Ax = b with A a symmetric positive definite matrix. The Jacobi update rule reads

x_{t+1} = x_t - D^{-1} \left( Ax_t - b \right).

It vaguely looks like the generic update rule for z but not quite since D^{-\frac12}A is not symmetric (unless D is a constant diagonal matrix d I). However, if you multiply the Jacobi update rule by D^{\frac12} from the left and let z_t = D^{\frac12} x_t, it can be rewritten as

z_{t+1} = z_t - \left( D^{-\frac12} AD^{-\frac12} z_t - D^{-\frac12} b \right).

So, up to an invertible diagonal transform, the Jacobi method essentially finds the minimizer of the quadratic form

f(z) \equiv \dfrac12 z^\top D^{-\frac12} A D^{-\frac12} z - z^\top D^{-\frac12}b

using a gradient descent with a constant unit step size ^\dagger. Likewise (if I recall correctly), you can show that the symmetric Gauss-Seidel method basically is a form coordinate-wise descent.

So what is the Chebyshev-Jacobi method ? – To derive the Chebyshev-Jacobi method, you ask the following question : among all descent methods of the form

z_{t+1} = z_t + \sum_{i=0}^{t-1} \alpha_i^{(t)} \left( z_{i+1} - z_i \right) + \alpha_t^{(t)} \nabla f(z_t).

how do I choose the set of weights \left\{ \alpha_i^{(t)} \right\} such that the method has the best worst-case convergence rate? The proof is not very complicated but a bit tedious so I’ll skip it. It mostly has to do with matrix polynomials, and in particular the Chebyshev polynomials, hence the name Chebyshev-Jacobi. At the end of the day, you can show that the descent method having the best worst-case convergence rate involves only z_t, the gradient \nabla f(z_t) = Pz_t - q and the first difference z_t - z_{t-1}. For our original problem (in terms of x), this leads to the non-stationary update rule

\left\{ \begin{aligned} \omega_t & = \left( 1 - \dfrac{1}{4a^2} \omega_{t-1} \right)^{-1}, \\ x_{t+1} & = \omega_t \left( I - \dfrac{2}{L + \mu} D^{-1} A \right) x_t + \dfrac{2}{L+\mu} \omega_t D^{-1}b + \left( \omega_{t} - 1 \right) x_{t-1}, \end{aligned} \right.

with \omega_0 = 2 (I think), x_0 is whatever your initial guess is and

a = \dfrac{L - \mu}{L + \mu}

with L and \mu being the largest and smallest eigenvalues of D^{-\frac12} A D^{-\frac12}. For our test problem, L and \mu can actually be computed analytically. In general, you’d need to have some estimates \hat{L} \geq L > 0 and 0 < \hat{\mu} \leq \mu. Obviously, the better your estimates, the closer you are to the optimal method.

Except for the extra scalar update for \omega_t and buffer to store x_{t-1}, you end up with a method that keeps the simplicity of the original Jacobi method, in particular its straightforward parallelization as it requires the inverse of a diagonal matrix (a purely local operation) while having a significantly better convergence rate. As @rsci mentioned, it also has the same worst-case convergence rate as Conjugate Gradient also, which is nice.

So why use Chebyshev-Jacobi instead of CG? – Mainly a matter of personal preferences. In practice though, CG may perform better (in terms of iteration count). One key advantage of Chebyshev-Jacobi however is that it requires no inner product while CG does (at least 2 or 3 if I recall correctly). For serial computations, it probably doesn’t change things much, but for large-scale parallel ones it does as inner product require a synchronization of all the processes. In contrast, for the Chebyshev-Jacobi method you can perform inner products only once in a while to compute the norm of the first difference x_{t+1} - x_{t} if you use the classical 2-norm, or even avoid them entirely if using for instance the 1-norm or \infty-norm (although it does require some form of all_reduce nonetheless). So basically, I think Chebyshev-Jacobi may have better scaling capabilities in terms of parallelization.

It is not a silver bullet though, most notably because you really do need good estimates of the extremal eigenvalues (which CG does not require). And that’s the only bit of information you use about the spectrum. In contrast, CG is a Krylov method so it essentially adapts on-the-fly to the actual distribution of eigenvalues and tends to have somewhat better convergence properties (even though the worst case is the same). The picture also changes quite drastically if you consider preconditioning. I don’t think Chebyshev-Jacobi can outperform preconditioned CG if you use a suitable preconditioner.


^\dagger : Since it uses a constant step size no matter what the Lipchitz constant of the quadratic form is, I believe this is another way to prove that the Jacobi method converges only for a subset of symmetric positive definite matrices. I may explore that idea some time.

I think I do not have typos, but ain’t entirely sure. I’ll double check with the latest draft whenever I have time.

4 Likes

As always, @loiseaujc dropping the most beautiful explanations <3

I have a stupid question though: suppose I am solving a linear system coming from a PDE discretisation in a temporal loop (e.g. pressure in incompressible NS). If I recall correctly, the system matrix depends not on the state of the system, but rather on the geometry of the problem (domain, boundary conditions, the kind of stencil/element you are using, the weak or strong formulation perhaps…). So my doubt now is: If I spend some time at the beginning estimating those two eigenvalues (say with the power method on one end and Rayleigh quotient on the other) I might actually really improve the repeated application of the Chebyshev-Jacobi, no? Or am I forgetting something?

That is exactly right. If you have to solve the exact same system multiple times (precisely like the Poisson equation for the incompressible and unsteady Navier-Stokes equations) but cannot derive the extremal eigenvalues analytically, you could spend some time off-line to get estimates using Rayleigh quotients or what not. The time spent there would then be amortized over the course of the simulation. Philosophically, it is pretty much the same with matrix factorization such as Cholesky: you pay a one-time \mathcal{O}(n^3) cost to factorize the matrix but it then gets massively amortized over the number of time steps since each solve now only requires \mathcal{O}(n^2) flops.

Damn. You just moved the Chebyshev-Jacobi method to the top of the list of things I have to implement in NEMO then.

1 Like

Not as standalone solvers. But both Jacobi and Gauss-Seidel are fundamentally important in multigrid methods where they serve as smoothers.

Multigrid methods are the fastest known solvers for this sort of elliptic problems (but also many others, even hyperbolic ones). So the question of Jacobi and Gauss-Seidel implementation efficiency is a very relevant one in practice.

2 Likes

Thanks a lot for this detailed explanation! A naive question about the eigenvalue estimate. Are there simple numerical methods for this?

I am looking forward to your articles!

PS: In your first equation “minimize f(x) …” I think it should be f(z)?

Multigrid methods are something I want to try but they seem pretty hard to implement…anyhow, thank you for this information.

Something about this post seemed familiar and then I remembered I did something very similar (in terms of comparing iterative methods) as a homework assignment for a Numerical Methods math class I took at Georgia Tech back in 1988 as part of my Ph.D. program. Amazingly, I was able to find the writeup I submitted for the assignment along with listings of my Fortran 77 code in my archives. Don’t have a clue as to why I still have them. Maybe I’ll try to resurrect the code and see if I still get the same results :grinning_face_with_smiling_eyes: I compared Jacoby, GS, SOR and preconditioned CG for two small problems. Preconditioners were a tridiagonal solver (which mimics an ILU), Jacobi, and a polynomial preconditioner that I never could get to converge. The two matrices were a periodic tri-diagonal system, and solution of a 2D Laplace equation Finite Difference system. SOR if you can find something close to an optimal acceleration parameter is hard to beat in terms of convergence but Jacobi-CG is usually a little better.

1 Like

The Gauss-Seidel method also makes a great case study for instruction level parallelism (ILP).

I ran your Jacobi example driver on my Apple M2 Pro, compiled using gfortran -O3 -mcpu=apple-m2 (GCC 15.2). The results I got were:

$ ./main
 ---------------
 RUNNING 2D CASE.
     - Number of points per direction :         512

 Do-concurrent solver :
     - Number of iterations :      138000
     - l2-norm of the error :   1.4417285169412902E-008
     - Time-to-solution     :   11.645257000112906
     - Max. pointwise error :   4.2726641669155185E-007

 Lexicographic solver :
     - Number of iterations :       74001
     - l2-norm of the error :   1.4546528745206831E-008
     - Time-to-solution     :   89.779160999925807
     - Max. pointwise error :   2.8254635656838056E-007

So the Jacobi solver is nearly 8x faster!

Luckily, there is a trick I learned about in the work of Treibig, Wellein and Hager (preprint found here, page 7). The examples is often featured in the RRZE courses on node-level and in-core performance engineering.

The trick here is to peel one iteration off the loop and regroup terms in a specific way:

        real(dp), intent(inout), contiguous :: u(:, :)
        real(dp), intent(in), contiguous :: b(:, :)
        real(dp), intent(in), value :: dx
        integer(ilp) :: i, j
        real(dp) :: tmp1, tmp2, dx2

        dx2 = dx**2

        do j = 2, n-1
            tmp1 = dx2*b(2,j) + (u(3,j) + u(2,j+1) + u(2,j-1))
            do i = 2, n-2
                tmp2 = dx2*b(i+1,j) + (u(i+2,j) + u(i+1,j+1) + u(i+1,j-1))
                u(i,j) = 0.25_dp*(u(i-1,j) + tmp1)
                tmp1 = tmp2
            end do
            u(n-1,j) = 0.25_dp*(u(n-2,j) + tmp1)
        end do

The benefit now is that the calculation of tmp2 and u(i,j) can be issued independently. The loop carried chain is reduced to The elapsed time is more than halved this way:

 Lexicographic solver :
     - Number of iterations :       74001
     - l2-norm of the error :   1.4546528745280479E-008
     - Time-to-solution     :   39.763964999932796
     - Max. pointwise error :   2.8254635673144457E-007

It is possible to carry this process even further:

        real(dp) :: t1, t2, um, dx2
        real(dp), parameter :: c  = 0.25_dp
        real(dp), parameter :: c2 = 0.0625_dp

        dx2 = dx**2

        do j = 2, n-1
            do i = 2, n-2, 2
                um = u(i-1,j)
                t1 = dx2*b(i,j)   + (u(i+1,j) + u(i,j+1)   + u(i,j-1))
                t2 = dx2*b(i+1,j) + (u(i+2,j) + u(i+1,j+1) + u(i+1,j-1))
                u(i,j)   = c*(um + t1)
                u(i+1,j) = c2*um + (c2*t1 + c*t2)
            end do
            if (mod(n, 2) == 1) then
                t1 = dx2*b(n-1,j) + (u(n,j) + u(n-1,j+1) + u(n-1,j-1))
                u(n-1,j) = c*(u(n-2,j) + t1)
            end if
        end do

which (with gfortran at least) delivers even further gains,

 Lexicographic solver :
     - Number of iterations :       74001
     - l2-norm of the error :   1.4546528746128828E-008
     - Time-to-solution     :   14.249884000048041
     - Max. pointwise error :   2.8254635646082771E-007

From 90 s we are down to 14 s, a six-fold improvement, and just ~3 s short of Jacobi.

Since floating-point arithmetic is not associative, the printed norms differ in the eleventh significant digit.

I also tested this with flang v22. The results I got were: 90 s, 37 s, 60 s. So the two-at-a-time variant regresses.

It is possible to explain these results using tools like the LLVM Machine Code Analyzer and/or OSACA, but that’s a topic for another day. I learned about this in these two videos:

This blog post is also a good introduction to the topic: Performance Debugging with llvm-mca: Simulating the CPU! - Johnny's Software Lab

6 Likes

Don’t know about modern texts but the classic text by Hageman and Young Applied Iterative Methods covers Chebyshev acceleration )(but not Chebyshev-Jacobi directly) in detail (as of 1981). The appendices has Fortran listings for a Chebyshev Acceleration Subroutine, a Cyclic-Chebyshev Semi-iterative subroutine, and an SOR subroutine. I don’t know if these were reproduced in the Dover edition reprint.

2 Likes

Much of this code was released in the ITPACK and NSPCG solver packages and is still around. See David R. KINCAID and David M. YOUNG, A brief review of the ITPACK project, Journal of Computational and Applied Mathematics 24 (1988) 121-127

I used ITPACK2C routines in the late 1980s - with great success - and revisited them a couple of years ago when resurrecting some work from back then. Not all of the solver routines ran with modern Fortran compilers, but it didn’t take long to work around the issues.

Edit: I recall getting the ITPACK2C code on magnetic tape by snail mail from the US and then having to get someone to copy/translate the tape into a form we could read on our VAXen. Times have changed.