/ blog /

The Arb matrix reloaded

June 29, 2018

Basic linear algebra in Arb is getting a significant upgrade in Arb 2.14 which is due to be released within a few weeks. The main improvements are more accurate linear solving and faster matrix multiplication.

Linear solving

The current development version of Arb (2.14-git) offers a dramatic improvement for solving large well-conditioned linear systems. To illustrate this, the following program constructs and solves $AX = B$ where $A$ is an $n \times n$ real matrix and $B$ is an $n \times 1$ vector. The program starts with 53-bit precision and doubles the precision until arb_mat_solve succeeds to prove that the matrix is invertible. The program then terminates and prints the first, middle and last entry in the solution vector with up to 20 digits. We take $n = 1000$ and we set $A$ to the size-$n$ discrete cosine transform (DCT) matrix with entries $$A_{j,k} = \sqrt{\frac{2}{n}} \cos\left(\tfrac{\pi j}{n} \left(k+\tfrac{1}{2}\right)\right).$$ We set $B$ to the vector of all ones. No special properties of the DCT matrix are being used here; it is just a convenient test matrix that is well-conditioned (the function arb_mat_dct used to create this matrix is a new addition to Arb; some other functions to generate useful special matrices have also been added).

#include "arb_mat.h"
#include "flint/profiler.h"

int main()
    arb_mat_t A, B, X;
    slong prec, n;
    n = 1000;
    arb_mat_init(A, n, n);
    arb_mat_init(B, n, 1);
    arb_mat_init(X, n, 1);
    for (prec = 53; ; prec *= 2)
        printf("prec = %ld\n", prec);
        arb_mat_dct(A, 0, prec);
        if (arb_mat_solve(X, A, B, prec))
            arb_printn(X->rows[0], 20, 0); printf("\n");
            arb_printn(X->rows[X->r/2], 20, 0); printf("\n");
            arb_printn(X->rows[X->r-1], 20, 0); printf("\n");
    return 0;

This is the before and after comparison:

Arb 2.13 Arb 2.14-git
prec = 53
prec = 106
prec = 212
prec = 424
prec = 848
prec = 1696
[+/- 2.99e+14]
[0.031587680085551469491 +/- 4.08e-22]
[0.0092445347862472445663 +/- 2.78e-24]
cpu/wall(s): 442.749 442.805
prec = 53
[28.47975797950 +/- 7.97e-12]
[0.031587680086 +/- 5.06e-13]
[0.0092445347862 +/- 9.55e-14]
cpu/wall(s): 18.348 18.349

The old version of arb_mat_solve uses direct Gaussian elimination to compute the LU factorization of the system matrix. This is numerically unstable in interval arithmetic in general, even when the system is well-conditioned. The precision needed for convergence scales like $O(n)$ and in this case exceeds 1000 bits. In fact, even after the solving succeeds, the top entry in the solution vector is wildly imprecise and we would have to double the precision one more time to get it precise.

The new version of arb_mat_solve completes 24 times faster, and more importantly, succeeds directly at 53-bit precision with only a few digits of precision loss! As a reference point, we can evaluate the matrix-vector product $A^T B$ (which is mathematically equivalent to $A^{-1} B$ for this $A$ since the DCT matrix is orthogonal); the corresponding entries then come out as:

[28.47975797950 +/- 2.78e-12]
[0.0315876800856 +/- 6.38e-14]
[0.0092445347862 +/- 6.03e-14]

We see that the error bounds from arb_mat_solve are just a small factor larger than the optimal bounds. This is a big deal in applications where the input matrices may be expensive to generate at high precision. In fact, arb_mat_solve still succeeds on the first try if we set the starting precision to 20 bits (and this is even faster):

prec = 20
[2.8e+1 +/- 0.505]
[0.032 +/- 9.06e-4]
[0.009 +/- 6.61e-4]
cpu/wall(s): 12.385 12.396

The main ingredient in the new arb_mat_solve is the Hansen-Smith algorithm from 1967. Instead of solving $AX = B$ directly, we compute an approximate inverse matrix $R \approx A^{-1}$ and then solve the preconditioned system $(RA)X = RB$. Gaussian elimination on the matrix $RA \approx I$ is numerically stable even in interval arithmetic (in fact, in some cases we can just bound the perturbation away from the identity matrix and skip the last solving step entirely). Computing $R$ can be done heuristically with the usual floating-point Gaussian elimination, and ball arithmetic only enters when computing $RA$, $RB$ and in the last solving step. The credit for implementing this algorithm in Arb goes to a contributor who wished to remain anonymous.

The drawback, of course, is that we are doing 3-4 times more work than just running Gaussian elimination once. This slowdown is offset to some extent by another improvement: I have rewritten Gaussian elimination to use a block recursive strategy which takes advantage of matrix multiplication, and matrix multiplication has been optimized (see below).

The following plot and table compare the performance on the DCT matrix benchmark as the dimension $n$ varies:

$n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
10 5.68e-05 0.000214 0.26
30 0.00107 0.00329 0.33
100 0.111 0.0558 1.99
300 3.658 0.845 4.33
1000 442.749 18.348 24.1

For sizes from about to 6 to 50 where the old algorithm succeeds without increasing the precision (for this particular matrix), the new algorithm is about three times slower, but the improvement in accuracy is dramatic. Here showing the first entry in the output vector, for different $n$:

n     Arb 2.13                          Arb 2.14-git
5     [2.1275693661542 +/- 5.10e-14]    [2.1275693661542 +/- 5.10e-14]
6     [2.3122784967091 +/- 8.64e-14]    [2.3122784967091 +/- 2.12e-14]
7     [2.482712359015 +/- 3.50e-13]     [2.4827123590148 +/- 5.33e-14]
8     [2.641845987495 +/- 7.03e-13]     [2.6418459874955 +/- 2.08e-14]
9     [2.79172023714 +/- 3.30e-12]      [2.7917202371376 +/- 4.33e-14]
10    [2.93381472088 +/- 9.40e-12]      [2.9338147208785 +/- 1.04e-14]
15    [3.55934770 +/- 1.52e-9]          [3.5593476994815 +/- 3.07e-14]     
20    [4.089760 +/- 5.21e-7]            [4.0897599643974 +/- 6.84e-14]
25    [4.559 +/- 4.29e-4]               [4.5586791661163 +/- 6.77e-14]        
30    [5.0 +/- 0.0546]                  [4.9835836367848 +/- 5.87e-14]        
35    [+/- 11.3]                        [5.3749570875008 +/- 5.67e-14]
40    [+/- 6.55e+3]                     [5.7396790611281 +/- 6.55e-14]
45    [+/- 3.93e+5]                     [6.082554137056 +/- 2.18e-13]

Using the slower but more accurate algorithm by default seems like a sensible tradeoff since losing several significant digits may be much more costly overall when propagated through a larger computation. It should be noted that the code switches back to direct Gaussian elimination when the precision is much higher than the expected precision loss, so no slowdown will occur when solving 20 by 20 systems at 1000-digit precision, for example. Users also have the option of selecting an algorithm manually by calling arb_mat_solve_lu (direct Gaussian elimination) or arb_mat_solve_precond (the Hansen-Smith algorithm) instead of the default arb_mat_solve.

Versus the machine

With $n = 1000$ and 53-bit precision, the new arb_mat_solve is "only" 150 times slower than numpy.linalg.solve (LAPACK dgesv) which takes 0.125 seconds to compute a non-certified solution. Roughly one order of magnitude is due to the inherent overhead of the certified solving algorithm, and another order of magnitude is due to using arbitrary-precision arithmetic. The second item is certainly improvable: a factor two could be saved by using double internally for the approximate solving, and another factor three could probably be saved with use of BLAS and other optimizations in FLINT. This would bring the factor down to 25 which is in the ballpark of machine-precision interval software such as INTLAB which reportedly solves a size-1000 system 12 times slower than MATLAB's backslash operator. In principle, the solving in Arb at precision up to 53 bits could be made nearly as fast as INTLAB by converting the matrices to double format and doing all calculations (including certifications) in this format internally. Implementing this correctly is complicated, however, and the design is more conservatively structured around arbitrary-precision arithmetic for now.

Ill-conditioned systems

For solving ill-conditioned systems, increasing the precision (typically to $O(n)$ bits) is unavoidable. If we change the DCT matrix to a Hilbert matrix, the old and new versions of arb_mat_solve compare as follows:

$n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
10 0.00010 0.000203 0.49
30 0.00529 0.0128 0.41
100 0.5 1.013 0.49
300 85.362 87.791 0.97

Again, a slight slowdown for ill-conditioned systems is a necessary tradeoff to optimize for well-conditioned systems and for global efficiency, and users who know that they are dealing with a system of this type in the range where direct Gaussian elimination is faster can always call arb_mat_solve_lu manually.

Matrix inversion

The improved linear solving also extends to matrix inversion, which just amounts to solving $AX = I$. In fact, the new code performs even better in this case compared to the old code: with the old code, solving with a square matrix right-hand side is about twice as expensive as with a vector right-hand side, but with the new algorithm, a matrix right-hand side is barely more expensive than a vector.

If we change the DCT benchmark code above to compute $A^{-1}$ (by calling arb_mat_inv instead of arb_mat_solve), we find that Arb 2.14-git is 50 times faster than Arb 2.13 when $n = 1000$.

Matrix multiplication

Matrix multiplication in Arb previously just used the classical algorithm, executed in ball arithmetic (arb_mat_mul_classical). There is also a multithreaded version (arb_mat_mul_threaded) of classical multiplication that helps for large matrices if the user has a lot of cores available. However, the classical algorithm is not ideal for arbitary-precision arithmetic, because there is a huge amount of overhead for each arithmetic operation.

Polynomial multiplication in Arb uses the trick of breaking the polynomials into blocks of coefficients that have nearly the same magnitude, rescaling these blocks to have integer coefficients, and multiplying the individual blocks using FLINT's blazingly fast $\mathbb{Z}[x]$ (fmpz_poly) arithmetic. The new matrix multiplication code in Arb 2.14-git uses an analogous scheme for matrices, multiplying blocks of coefficients with similar magnitude via FLINT's fmpz_mat.

The algorithm in more detail

The code implements a scaling trick for individual blocks: if $A_i$ and $B_i$ are the submatrices to be multiplied, the algorithm inspects the exponent changes along rows of $A_i$ and along columns of $B_i$ to determine diagonal matrices (specifically, with power-of-two entries) $D_1$ and $D_2$ such that $D_1 A_i$ and $B_i D_2$ are integer matrices with nearly minimal height. It then evaluates $A_i B_i = D_1^{-1} (D_1 A_i) (B_i D_2) D_2^{-1}$.

If the input matrices $A$ and $B$ have coefficients of nearly uniform magnitude (in individual rows of $A$ and columns of $B$), a single block $A = A_i$ and $B = B_i$ can be used. The complicated part of the algorithm is to split non-uniformly scaled matrices into blocks that are optimal for efficiency. On one hand, the blocks should be as large as possible, since this makes fmpz_mat matrix multiplication more advantageous. On the other hand, when the coefficient magnitudes vary, larger blocks result in larger integers which slows things down. I have implemented a fairly simple heuristic which consists of greedily scanning forward for maximal consecutive spans of columns of $A$ and rows of $B$ that fit within a limited exponent range. I make no claims that this is the best way to do it, but it works reasonably well.

Integer matrix multiplication is a bit faster already for small matrices since it has less overhead than ball arithmetic. It becomes much faster for huge matrices (say $n > 100$) since it can use a multimodular algorithm performing one or several matrix multiplications modulo single-word primes. FLINT also uses Strassen multiplication so the complexity is in fact $O(n^{2.81})$ and not $O(n^3)$.

Integer matrices are used for the multiplying the matrices of midpoints. When the matrices are inexact, say $A = A_m \pm A_r$ and $B = B_m \pm B_r$, the two additional matrix products $(|A_m| + A_r) B_r$ and $A_r |B_m|$ are computed by splitting into scaled blocks and using double arithmetic. This is faster and works well since all entries are nonnegative making it trivial to compute tight upper bounds.

One could implement Strassen multiplication and other tricks directly in Arb, but this is not a good idea in most cases since it is less numerically stable than classical matrix multiplication. The new matrix multiplication code is designed to give at least as good error bounds as the classical algorithm for every individual coefficient in the output. In fact, the error bounds are usually a bit better since fewer internal rounding operations are performed.

Future improvements are certainly possible. Matrix multiplication in FLINT could probably be sped up by about a factor three using BLAS and other optimizations (FLINT currently uses non-vectorized 64-bit integer arithmetic for modular matrix multiplications and has unoptimized basecase code for modular reduction and Chinese remaindering).

Some benchmarks

I have done a bit of benchmarking with three families of test matrices:

For this test, we set both operands to the same matrix, and we compare multiplication speed at a precision of 53, 128, 333 and 1024 bits.

Hilbert matrices

The following plot shows the timings for Hilbert matrices. The different levels of precision are indicated with different colors and in the left subplot, the timings for the old code are shown as dashed curves while the timings for the new code are shown as solid curves:

At 1-2 words of precision, the new code is five times faster than the old code even for relatively small matrices and a factor 20 faster for matrices with dimension $n$ in the thousands. The time for an $n = 1000$ matrix multiplication is down from two minutes to 4-10 seconds. At hundreds of digits, the advantage of the new algorithm is starting to diminish but there is still a large speedup as soon as the dimension also gets large.

$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
53 10 7.8e-05 3.2e-05 2.44
53 30 0.00191 0.00036 5.31
53 100 0.075 0.0103 7.28
53 300 2.014 0.172 11.7
53 1000 89.329 4.681 19.1
$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
128 10 8.9e-05 8.3e-05 1.07
128 30 0.00229 0.00135 1.70
128 100 0.091 0.0231 3.94
128 300 2.428 0.321 7.56
128 1000 95.169 7.443 12.8

$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
333 10 0.000129 0.000103 1.25
333 30 0.0043 0.00222 1.94
333 100 0.155 0.054 2.87
333 300 6.038 0.708 8.53
333 1000 257.607 16.122 16.0
$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
1024 10 0.00056 0.00062 0.90
1024 30 0.0093 0.007 1.33
1024 100 0.371 0.218 1.70
1024 300 11.111 2.41 4.61
1024 1000 454.495 49.298 9.22

Small-integer matrices

The speedup is even more dramatic when multiplying Arb matrices with small integer entries. This is now essentially as fast as using fmpz_mat directly, up to the conversion overhead. The plot below compares the performance for multiplying the matrices with entries $A_{i,j} = B_{i,j} = i + j + 1$. A single $n = 1000$ multiplication now takes one second, down from a minute, and the speedup continues to grow to a factor 100 beyond $n = 2000$.

$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
53 10 4e-05 1.26e-05 3.17
53 30 0.0011 0.000121 9.09
53 100 0.045 0.00188 23.9
53 300 1.495 0.035 42.7
53 1000 50.957 0.955 53.4
53 2000 568.174 6.412 88.6

(Here, the precision does not matter; the matrices and their product are exactly representable at 53-bit precision, and increasing the precision does not cause any slowdown.)

Pascal matrices

With Pascal matrices (multiplied by the factor $\pi$), the speedup is modest due to the need to use many blocks. Nevertheless, for large enough $n$, the new algorithm saves a factor 2-4 depending on the precision.

$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
53 10 8.1e-05 3.8e-05 2.13
53 30 0.00218 0.00148 1.47
53 100 0.088 0.05 1.76
53 300 2.569 1.305 1.97
53 1000 96.37 40.518 2.38
$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
128 10 8.9e-05 8.7e-05 1.02
128 30 0.00237 0.00275 0.86
128 100 0.092 0.064 1.44
128 300 2.819 1.92 1.47
128 1000 100.019 48.427 2.07

$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
333 10 0.000145 0.000116 1.25
333 30 0.0043 0.00292 1.47
333 100 0.179 0.095 1.88
333 300 7.371 2.21 3.34
333 1000 288.91 77.335 3.73
$p$ $n$ Time, Arb 2.13 Time, Arb 2.14-git Speedup
1024 10 0.00036 0.00033 1.09
1024 30 0.0116 0.0098 1.18
1024 100 0.493 0.231 2.13
1024 300 16.088 4.965 3.24
1024 1000 453.439 154.31 2.94

Complex matrices and more

Implementing fast multiplication, solving and matrix inverse for complex matrices (acb_mat) is a work in progress that will be finished before Arb 2.14 is released.

Determinants of real and complex matrices can also be improved using similar preconditioning tricks, but this requires a separate implementation which might be postponed until later.