Case Study 1: The Solver That Divides by Zero
"The textbook algorithm and the reliable algorithm are not the same algorithm."
Executive Summary
You have inherited a small linear-system solver — twenty-odd lines of clean, readable Gaussian elimination
that a predecessor wrote rather than link a library. It works. It has worked for years, on the systems it
has been fed. This study reads that code, understands exactly what it does, and then hands it a system that a
first-year linear-algebra student would solve in their head — and watches it produce NaN. The bug is not a
typo; it is the algorithm, which omits the one step (partial pivoting) that turns textbook Gaussian
elimination into a reliable one. We then replace the whole routine with a single dgesv call, confirm it
solves the case that broke the original, and draw the lesson the chapter has been building toward: the reason
you call LAPACK is not laziness or speed alone — it is that a specialist has already handled the failure
modes you have not thought of yet.
Skills applied: reading and analyzing an existing numerical routine; the dgesv calling convention and
partial pivoting (§21.3); interpreting info (§21.3); the leading dimension (§21.3); the connection between
pivoting and conditioning (§21.3 and Chapter 20); the
"don't ship your own" judgment (§21.2).
Background
The routine solves $A\mathbf{x} = \mathbf{b}$ by the method every numerical-methods course teaches: forward elimination to reduce $A$ to upper-triangular form, then back substitution to read off the unknowns. It is correct mathematics. Here it is, as you found it — read it and satisfy yourself that it does what it claims before we break it:
! Inherited: textbook Gaussian elimination, NO pivoting. Reads clearly; fails quietly.
subroutine naive_solve(a, b, x, n)
use, intrinsic :: iso_fortran_env, only: dp => real64
integer, intent(in) :: n
real(dp), intent(inout) :: a(n, n), b(n)
real(dp), intent(out) :: x(n)
integer :: k, i
real(dp) :: m
do k = 1, n - 1 ! forward elimination
do i = k + 1, n
m = a(i, k) / a(k, k) ! multiplier — divides by the diagonal PIVOT
a(i, k:n) = a(i, k:n) - m * a(k, k:n)
b(i) = b(i) - m * b(k)
end do
end do
do i = n, 1, -1 ! back substitution
x(i) = (b(i) - dot_product(a(i, i+1:n), x(i+1:n))) / a(i, i)
end do
end subroutine naive_solve
The whole story is in one line: m = a(i, k) / a(k, k). The algorithm divides by the diagonal element
a(k, k) — the pivot — and never asks whether that is a safe thing to do.
Phase 1 — Confirm It Works on a Benign System
Never study a bug without first confirming the code works when it should. Feed it a well-behaved 2×2 system,
$$ \begin{bmatrix} 2 & 1 \\ 1 & 3 \end{bmatrix}\mathbf{x} = \begin{bmatrix} 4 \\ 7 \end{bmatrix}, \qquad \text{solution } \mathbf{x} = (1, 2). $$
Trace it by hand: the first pivot is a(1,1) = 2, comfortably nonzero. The multiplier is $m = 1/2$; row 2
becomes $[0,\ 2.5]$ with right-hand side $7 - 0.5\cdot4 = 5$; back substitution gives $x_2 = 5/2.5 = 2$ and
$x_1 = (4 - 1\cdot2)/2 = 1$. The routine returns $(1, 2)$, exactly right. So far the inheritance looks sound,
and you can see why nobody has touched it: on ordinary systems, it is correct.
Phase 2 — The System That Breaks It
Now a system that is, if anything, easier — you can solve it by inspection:
$$ \begin{bmatrix} 0 & 1 \\ 1 & 1 \end{bmatrix}\mathbf{x} = \begin{bmatrix} 1 \\ 2 \end{bmatrix}. $$
The first equation says $y = 1$; the second then says $x + 1 = 2$, so $x = 1$. The answer is $(1, 1)$, and
there is nothing pathological about the system — its determinant is $-1$, it is perfectly well-conditioned,
it has a unique solution. Hand it to naive_solve and watch the first line detonate:
program break_it
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: a(2,2), b(2), x(2)
a = reshape([ 0.0_dp, 1.0_dp, 1.0_dp, 1.0_dp ], [2, 2], order=[2, 1])
b = [ 1.0_dp, 2.0_dp ]
call naive_solve(a, b, x, 2)
print *, 'naive_solve gives x =', x
end program break_it
The very first pivot is a(1,1) = 0. The multiplier m = a(2,1) / a(1,1) is $1 / 0$, which in IEEE
arithmetic is $+\infty$ (gfortran does not trap it by default). From there the poison spreads: $\infty \cdot
0$ is `NaN`, the eliminated row fills with `NaN` and $-\infty$, and back substitution divides one indefinite
quantity by another. The program prints not the answer but its wreckage:
naive_solve gives x = NaN NaN
(The exact spacing of NaN is compiler-specific; the point is that it is not a number.) The routine did not
warn you, did not stop, did not return an error code. It computed NaN with total confidence and handed it
back as if it were an answer. In a larger program that value would flow downstream and quietly corrupt
everything it touched. This is the failure mode the library exists to prevent.
Phase 3 — The One Missing Idea: Partial Pivoting
The system is fine; the algorithm is incomplete. Textbook Gaussian elimination assumes the pivot is nonzero; reliable Gaussian elimination guarantees it by reordering. Before eliminating with column $k$, partial pivoting searches that column for its largest-magnitude entry and swaps that row up into the pivot position. For our system, column 1 is $(0, 1)$; the larger magnitude is the $1$ in row 2, so pivoting swaps the rows:
$$ \begin{bmatrix} 1 & 1 \\ 0 & 1 \end{bmatrix}\mathbf{x} = \begin{bmatrix} 2 \\ 1 \end{bmatrix}, $$
and now the pivot is $1$, the elimination is trivial, and back substitution gives $(1, 1)$ — the right
answer. Partial pivoting costs almost nothing (a search and a row swap per column) and it is the difference
between a solver that works on the systems it happens to be given and one that works on every nonsingular
system. It is precisely the step naive_solve omits, and precisely the step LAPACK's dgesv performs
automatically, recording the swaps in its ipiv array.
Phase 4 — Replace the Routine with dgesv
You could add pivoting to naive_solve — it is a good exercise. You should not ship it. The robust,
tested, pivoting-by-default solver already exists and is one call away. Here is the replacement, on the exact
system that produced NaN:
program robust_solve
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer, parameter :: n = 2
real(dp) :: a(n,n), b(n)
integer :: ipiv(n), info
a = reshape([ 0.0_dp, 1.0_dp, 1.0_dp, 1.0_dp ], [n, n], order=[2, 1])
b = [ 1.0_dp, 2.0_dp ]
call dgesv(n, 1, a, n, ipiv, b, n, info) ! partial pivoting handled internally
if (info /= 0) then
print '(a, i0)', 'dgesv reported info = ', info
error stop 1
end if
print '(a)', 'x ='
print '(f8.4)', b
end program robust_solve
$ gfortran -std=f2018 -Wall robust_solve.f90 -o robust -llapack -lblas && ./robust
x =
1.0000
1.0000
The system that reduced the inherited routine to NaN, dgesv solves without comment, because pivoting is
not an optional extra to LAPACK — it is built in. And note the discipline the library enforces: it hands
you an info you are expected to check. Had the matrix been genuinely singular, info would have come back
positive and you would have known, rather than discovering it three modules downstream when a plot came out
wrong.
Phase 5 — The Deeper Danger: Pivots That Are Small, Not Zero
A zero pivot at least fails loudly, in NaN. The subtler menace is a pivot that is merely tiny. Consider
$$ \begin{bmatrix} 10^{-20} & 1 \\ 1 & 1 \end{bmatrix}\mathbf{x} = \begin{bmatrix} 1 \\ 2 \end{bmatrix}, $$
whose true solution is very close to $(1, 1)$. naive_solve does not divide by zero here — the pivot is
$10^{-20}$, not $0$ — so it runs to completion and returns a number. That is worse. The multiplier becomes
$1/10^{-20} = 10^{20}$, an enormous factor that, when it multiplies the pivot row and subtracts, utterly
swamps the modest entries carrying the actual answer: computing $1 - 10^{20}$ in double precision simply
loses the $1$, because it falls below the rounding resolution of $10^{20}$. This is catastrophic
cancellation, the hazard of Chapter 20, and it leaves the routine
confidently reporting a badly wrong $x$ — often something near $0$ instead of $1$. Partial pivoting avoids the
whole disaster by refusing to use a tiny pivot when a larger one is available in the column: dgesv swaps the
rows, uses the $1$ as the pivot, keeps every multiplier at or below $1$ in magnitude, and returns the correct
$(1, 1)$.
| System | naive_solve (no pivoting) |
dgesv (partial pivoting) |
|---|---|---|
| $\begin{bmatrix}2&1\\1&3\end{bmatrix},\ (4,7)$ | $(1, 2)$ — correct | $(1, 2)$ — correct |
| $\begin{bmatrix}0&1\\1&1\end{bmatrix},\ (1,2)$ | NaN — divide by zero |
$(1, 1)$ — correct |
| $\begin{bmatrix}10^{-20}&1\\1&1\end{bmatrix},\ (1,2)$ | $\approx(0, 1)$ — silently wrong | $\approx(1, 1)$ — correct |
The pattern is the argument of the whole chapter, made concrete. The inherited code is not wrong in any line; it faithfully implements the algorithm as it appears in a textbook. It is incomplete in the way that real numerical software cannot afford to be, and the missing piece — pivoting for stability — is exactly the kind of hard-won robustness that decades of LAPACK development have baked in. You call the library not because you could not write elimination, but because the version worth trusting is not the one in the textbook.
Discussion Questions
naive_solvedivides bya(k, k)with no check. Add a guard that callserror stopifabs(a(k,k))is below a small threshold. Does that make the routine correct, or merely loud? What can pivoting do that a guard cannot?- The zero-pivot system is perfectly well-conditioned — its answer is not sensitive to small perturbations — yet the naive routine fails on it. Explain, in one sentence, why conditioning (a property of the matrix) and stability (a property of the algorithm) are different things.
dgesvrecords its row swaps inipivbut you never read it. Sketch what the routine would have to return to you instead if it did not keep that array — and why keeping it is cheaper than the alternative.
Your Turn: Extensions
- Option A. Add partial pivoting to
naive_solveyourself: before each elimination step, findmaxloc(abs(a(k:n, k))), swap that row up in bothaandb, then proceed. Test it on all three systems in the table and confirm it now matchesdgesv. (This is the best possible way to appreciate what the library does for free.) - Option B. Instrument the tiny-pivot case: solve the $10^{-20}$ system with both routines and print the residual $\lVert A\mathbf{x} - \mathbf{b}\rVert$ from each (using a saved copy of $A$). Which routine's residual betrays the wrong answer, and which hides it?
- Option C. Replace the
dgesvcall with thefortran-langstdlibsolvewrapper (stdlib_linalg), which type-checks its arguments. Note how much of the boilerplate — theipivarray, theinfocheck, the copy ofa— disappears, and decide when you would still want the rawdgesv.
Key Takeaways
- The reason to call LAPACK is not only speed; it is robustness you did not write — partial pivoting, the step that separates textbook Gaussian elimination from a solver you can trust on every nonsingular system.
- A zero pivot fails loudly (
NaN); a tiny pivot fails silently (catastrophic cancellation, Chapter 20), which is worse. Pivoting prevents both by never using a small pivot when a larger one is available. dgesvperforms pivoting automatically, records it inipiv, and forces you to receive aninfostatus — turning a silent failure into a checkable one.- Inherited numerical code can be perfectly readable and still be subtly incomplete. When you find a hand-rolled solver, the right move is usually not to fix it but to replace it with the library and delete it.