Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO LVII 2021-02-20 · 6 MIN · SHORT-FORM

Linear Algebra in Go: Solving Linear Systems

Linear Algebra in Go Part 3

Diagram · folio lvii
quadrantChart
    title Linear System Solvers
    x-axis Iterative --> Direct
    y-axis Sparse matrices --> Dense matrices
    quadrant-1 Direct dense
    quadrant-2 Iterative dense
    quadrant-3 Iterative sparse
    quadrant-4 Direct sparse
    Gaussian elimination: [0.85, 0.85]
    LU decomposition: [0.80, 0.80]
    Cholesky pos-def: [0.75, 0.88]
    Conjugate Gradient: [0.20, 0.30]
    GMRES: [0.15, 0.25]
    Jacobi: [0.10, 0.50]
    Gauss-Seidel: [0.18, 0.55]
    Sparse LU: [0.85, 0.20]

This article covers solving linear systems in Go using the gonum library, including direct methods with mat.Solve, LU decomposition, and Cholesky decomposition for positive-definite matrices.

Linear Algebra: Golang Series - View all articles in this series.

Previous articles in this series:

  1. Linear Algebra in Go: Vectors and Basic Operations
  2. Linear Algebra in Go: Matrix Fundamentals

This continues from Part 2, linked above.


§2026 Update

The gonum solvers here all still compile and run on current Go: SolveVec, mat.LU, mat.Cholesky, mat.QR, and mat.SVD are unchanged. Two things worth a word.

On the “Go vs Python” framing below: for dense linear algebra, do not expect Go to be dramatically faster than NumPy. Both gonum and NumPy hand the heavy lifting to optimized BLAS and LAPACK routines, so a matrix solve lands in roughly the same place either way. Go’s real advantages here are operational, a single static binary with no interpreter to ship, real concurrency, and easy embedding in a service, not raw FLOPs.

And as in the previous article, this is dense-matrix territory. For large sparse systems the iterative methods in the diagram up top (Conjugate Gradient, GMRES) are what you want, and gonum’s support for them is thin, so that is another point where you may reach past gonum.


The article continues below. The 2026 Update above covers what’s worth knowing today.

§The Linear System Problem

The goal is to solve $\boldsymbol{A}\vec{x} = \vec{b}$ for $\vec{x}$, where $\boldsymbol{A}$ is an $n \times n$ matrix.

§Using mat.Solve

The simplest approach in gonum:

package main

import (
    "fmt"
    "gonum.org/v1/gonum/mat"
)

func main() {
    // System: 2x + 3y = 8
    //         x - y = 1
    A := mat.NewDense(2, 2, []float64{
        2, 3,
        1, -1,
    })
    b := mat.NewVecDense(2, []float64{8, 1})

    // Solve Ax = b
    var x mat.VecDense
    err := x.SolveVec(A, b)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Println("Solution:")
    fmt.Printf("x = %.4f\n", x.AtVec(0))
    fmt.Printf("y = %.4f\n", x.AtVec(1))

    // Verify: A*x should equal b
    var result mat.VecDense
    result.MulVec(A, &x)
    fmt.Printf("\nVerification (A*x): [%.2f, %.2f]\n",
        result.AtVec(0), result.AtVec(1))
}

Output:

Solution:
x = 2.2000
y = 1.2000

Verification (A*x): [8.00, 1.00]

§Visualizing the Solution

A system of two linear equations represents two lines. The solution is their intersection:

package main

import (
    "image/color"

    "gonum.org/v1/plot"
    "gonum.org/v1/plot/plotter"
    "gonum.org/v1/plot/vg"
    "gonum.org/v1/plot/vg/draw"
)

func main() {
    p := plot.New()
    p.Title.Text = "Linear System: Two Lines Intersecting"
    p.X.Label.Text = "x"
    p.Y.Label.Text = "y"
    p.X.Min, p.X.Max = -1, 5
    p.Y.Min, p.Y.Max = -1, 4

    // Line 1: 2x + 3y = 8  =>  y = (8 - 2x) / 3
    line1Pts := make(plotter.XYs, 100)
    for i := range line1Pts {
        x := -1.0 + float64(i)*0.06
        line1Pts[i] = plotter.XY{X: x, Y: (8 - 2*x) / 3}
    }
    l1, _ := plotter.NewLine(line1Pts)
    l1.Color = color.RGBA{R: 66, G: 133, B: 244, A: 255}
    l1.Width = vg.Points(2)

    // Line 2: x - y = 1  =>  y = x - 1
    line2Pts := make(plotter.XYs, 100)
    for i := range line2Pts {
        x := -1.0 + float64(i)*0.06
        line2Pts[i] = plotter.XY{X: x, Y: x - 1}
    }
    l2, _ := plotter.NewLine(line2Pts)
    l2.Color = color.RGBA{R: 234, G: 67, B: 53, A: 255}
    l2.Width = vg.Points(2)

    // Solution point (2.2, 1.2)
    solPts := plotter.XYs{{X: 2.2, Y: 1.2}}
    sol, _ := plotter.NewScatter(solPts)
    sol.GlyphStyle.Color = color.RGBA{R: 52, G: 168, B: 83, A: 255}
    sol.GlyphStyle.Radius = vg.Points(6)
    sol.GlyphStyle.Shape = draw.CircleGlyph{}

    p.Add(l1, l2, sol)
    p.Save(6*vg.Inch, 5*vg.Inch, "systems.png")
}

Two lines intersecting at the solution point

The blue line represents 2x + 3y = 8, the red line represents x - y = 1, and the green point marks the solution (2.2, 1.2).

§LU Decomposition

LU decomposition factors a matrix as $\boldsymbol{A} = \boldsymbol{L}\boldsymbol{U}$ where $\boldsymbol{L}$ is lower triangular and $\boldsymbol{U}$ is upper triangular:

import "gonum.org/v1/gonum/mat"

func luDecomposition() {
    A := mat.NewDense(3, 3, []float64{
        2, -1, 0,
        -1, 2, -1,
        0, -1, 2,
    })

    // Compute LU decomposition
    var lu mat.LU
    lu.Factorize(A)

    // Extract L and U
    var L, U mat.TriDense
    lu.LTo(&L)
    lu.UTo(&U)

    fmt.Println("L (lower triangular):")
    fmt.Printf("%v\n", mat.Formatted(&L))

    fmt.Println("\nU (upper triangular):")
    fmt.Printf("%v\n", mat.Formatted(&U))

    // Solve using LU
    b := mat.NewVecDense(3, []float64{1, 0, 1})
    var x mat.VecDense
    err := lu.SolveVecTo(&x, false, b)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Println("\nSolution x:")
    fmt.Printf("%v\n", mat.Formatted(&x))
}

§Cholesky Decomposition

For symmetric positive-definite matrices, Cholesky decomposition is more efficient: $\boldsymbol{A} = \boldsymbol{L}\boldsymbol{L}^T$

func choleskyDecomposition() {
    // Symmetric positive-definite matrix
    A := mat.NewSymDense(3, []float64{
        4, 2, 2,
        2, 5, 1,
        2, 1, 6,
    })

    // Compute Cholesky decomposition
    var chol mat.Cholesky
    ok := chol.Factorize(A)
    if !ok {
        fmt.Println("Matrix is not positive-definite")
        return
    }

    // Extract L
    var L mat.TriDense
    chol.LTo(&L)

    fmt.Println("Cholesky factor L:")
    fmt.Printf("%v\n", mat.Formatted(&L))

    // Solve using Cholesky
    b := mat.NewVecDense(3, []float64{1, 2, 3})
    var x mat.VecDense
    err := chol.SolveVecTo(&x, b)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Println("\nSolution:")
    fmt.Printf("%v\n", mat.Formatted(&x))
}

§QR Decomposition for Least Squares

For overdetermined systems (more equations than unknowns), use QR decomposition:

func qrLeastSquares() {
    // Overdetermined system: 4 equations, 2 unknowns
    A := mat.NewDense(4, 2, []float64{
        1, 1,
        1, 2,
        1, 3,
        1, 4,
    })
    b := mat.NewVecDense(4, []float64{2.1, 3.9, 6.2, 7.8})

    // QR decomposition
    var qr mat.QR
    qr.Factorize(A)

    // Solve least squares
    var x mat.VecDense
    err := qr.SolveVecTo(&x, false, b)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Println("Least squares solution:")
    fmt.Printf("Intercept: %.4f\n", x.AtVec(0))
    fmt.Printf("Slope: %.4f\n", x.AtVec(1))

    // Compute residuals
    var residuals mat.VecDense
    residuals.MulVec(A, &x)
    residuals.SubVec(&residuals, b)

    residualNorm := mat.Norm(&residuals, 2)
    fmt.Printf("\nResidual norm: %.4f\n", residualNorm)
}

Output:

Least squares solution:
Intercept: 0.1500
Slope: 1.9400

Residual norm: 0.2864

§Benchmarking: Go vs Python

Go typically offers significant performance advantages:

import (
    "fmt"
    "time"
    "gonum.org/v1/gonum/mat"
)

func benchmarkSolve() {
    sizes := []int{100, 500, 1000}

    for _, n := range sizes {
        // Generate random matrix and vector
        data := make([]float64, n*n)
        for i := range data {
            data[i] = float64(i%17) - 8
        }
        A := mat.NewDense(n, n, data)

        bData := make([]float64, n)
        for i := range bData {
            bData[i] = float64(i % 11)
        }
        b := mat.NewVecDense(n, bData)

        // Time the solve
        start := time.Now()
        var x mat.VecDense
        x.SolveVec(A, b)
        elapsed := time.Since(start)

        fmt.Printf("Size %4d x %4d: %v\n", n, n, elapsed)
    }
}

§Condition Number

Check numerical stability by computing the condition number:

func conditionNumber() {
    A := mat.NewDense(3, 3, []float64{
        1, 2, 3,
        4, 5, 6,
        7, 8, 9.0001,  // Nearly singular
    })

    // SVD to compute condition number
    var svd mat.SVD
    svd.Factorize(A, mat.SVDThin)

    values := svd.Values(nil)
    cond := values[0] / values[len(values)-1]

    fmt.Printf("Singular values: %v\n", values)
    fmt.Printf("Condition number: %.2e\n", cond)
}

Output:

Singular values: [16.848158309533105 1.0683452562878242 1.6666990740008272e-05]
Condition number: 1.01e+06

The tiny third singular value relative to the first is what drives the huge condition number, a sign this matrix is nearly singular and that solutions against it will be sensitive to small changes.

§Summary

This article covered:

  • Direct solving with mat.Solve
  • LU decomposition for general square matrices
  • Cholesky decomposition for symmetric positive-definite matrices
  • QR decomposition for least squares
  • Performance benchmarking
  • Condition number for numerical stability

The next article covers eigenvalue problems in Go.

§Resources

§Next: Eigenvalue Problems

Check out the next article in this series, Linear Algebra in Go: Eigenvalue Problems.


Linear Algebra: Golang Series - View all articles in this series.

← back to all notes