English
Linear algebra and performance
BLAS levels and matrix multiplication
BLAS (Basic Linear Algebra Subprograms) is a standard set of linear algebra subroutines https://www.netlib.org/blas/ on which LAPACK, NumPy, MATLAB, and machine learning frameworks rely. Optimized implementations (Intel oneMKL, OpenBLAS) are written with intrinsics for each processor. The subroutines are divided into three levels by complexity (Table 7.5).
Table 7.5. BLAS levels
| Level | Examples | Operation | Complexity |
|---|---|---|---|
| 1: vector–vector | dot, axpy, nrm2 | ||
| 2: matrix–vector | gemv | ||
| 3: matrix–matrix | gemm |
Level 1 operations are limited by memory bandwidth: there are only one or two arithmetic operations per number. Level 3 operations perform
Loop order
The product i * n + j in the array. The three nested loops can be arranged in six ways without changing the result, but the performance does change (Fig. 7.5).
Figure 7.5. Loop order and memory access
- ijk (as in the formula): the innermost loop over
ktraverses a row ofAsequentially, but traverses matrixBby column, in jumps of elements. The column is scattered across the whole matrix, which for occupies 32 MB and does not fit even in the L3 cache (16 MB), so almost every access toBis a cache miss. - ikj: the loop over
jis innermost, and all three matrices are traversed by rows, sequentially. The inner loopc[i*n + j] += aik * b[k*n + j]has the form of anaxpyoperation and vectorizes easily.
In the “Matrix multiplication” example for B occupies 8 MB and fits in the L3 cache.
Blocked matrix multiplication
The ikj order traverses rows sequentially, but for large B (16 KB for B does not fit in the cache. Blocked (tiled) multiplication divides the matrices into tiles of size
Figure 7.6. Blocked matrix multiplication
Three double occupy
- SIMD: the inner loop over
j(anaxpyoperation on a tile row) is vectorized withVector256; - threads: the rows of tiles of matrix
Care independent, so the outer loop runs throughParallel.For; each thread writes only to its own rows ofC, and no synchronization is needed.
Computational performance is measured in FLOPS (floating-point operations per second); GFLOPS means billions of them. Multiplying
where
Table 7.6. Multiplying
| Method | Time, ms | GFLOPS | |
|---|---|---|---|
| ijk | 48,506.6 | 0.35 | 1.0 |
| ikj | 7153.7 | 2.40 | 6.8 |
| blocked, 64 tiles | 4715.3 | 3.64 | 10.3 |
blocked + SIMD (Vector256) | 2276.8 | 7.55 | 21.3 |
blocked + SIMD + Parallel.For | 461.1 | 37.26 | 105.2 |
The 105× speedup consists of three factors: the cache (10×), SIMD (2×), and 16 threads (4.9×). Optimized BLAS libraries achieve even more: they write the operation kernel with intrinsics and use several levels of tiles for L1, L2, and L3.
Parallel methods for solving linear systems
A system of linear algebraic equations
Gaussian elimination
For each column
At step Parallel.For, and each row update is vectorized (an axpy operation):
cs
// a – augmented n × (n + 1) matrix, w = n + 1.
Parallel.For(k + 1, n, i =>
{
double f = a[i * w + k] / a[k * w + k];
ReadOnlySpan<double> rowK = a.AsSpan(k * w + k, w - k);
Span<double> rowI = a.AsSpan(i * w + k, w - k);
// rowI = rowK · (-f) + rowI – vectorized
TensorPrimitives.MultiplyAdd(rowK, -f, rowI, rowI);
});Pivot selection and row swapping remain sequential, and the steps Parallel.For costs more than the work: when
The Jacobi method
The Jacobi method computes the new approximation only from the old one:
All Parallel.For. Iterations are repeated until
Red–black Gauss–Seidel method
The Gauss–Seidel method uses new values of Parallel.For over rows i, and within a row the loop over j starts at 1 + (i + color + 1) % 2 and advances in steps of 2, updating u[i*n + j] with the average of its four neighbors.
A red node reads only black neighbors, which do not change during that half-step, so there is no race. For a
Measuring and analyzing performance
Vectorized code is measured the same way as parallel code (Topic 1): Release configuration, warmup, median of runs. SIMD has some specifics:
- JIT tiers: a method with a loop is first compiled without optimizations; before measuring, call the method dozens of times and give the JIT time for the optimized recompilation (Tier 1);
- small and large data: for arrays of a few elements, vector code can be slower than scalar code because of setup and the tail; for large arrays, memory limits the time;
- alignment: the random placement of an array in memory changes the time between runs.
BenchmarkDotNet and the disassembler
BenchmarkDotNet (Topic 4) is the most convenient tool for comparing approaches. The [DisassemblyDiagnoser] attribute additionally saves the machine code of each method https://benchmarkdotnet.org/articles/features/disassembler.html:
The methods being compared are marked with the [Benchmark] attribute (one of them with Baseline = true), and the class with [DisassemblyDiagnoser(maxDepth: 1)]. For the sum of a million float values on the i9-11900KF, BenchmarkDotNet 0.15.8 showed: scalar loop – 870 µs, Vector<T> – 112 µs, Vector256 – 112 µs, TensorPrimitives.Sum – 58 µs (a ratio of 0.07, that is, 15 times faster) (Fig. 7.7). The Code Size column shows the machine code size: 32 bytes for the scalar loop and 903 bytes for TensorPrimitives, which has separate paths for different widths and lengths.
Screenshot
Windows Terminal: dotnet run -c Release of the SumBenchmarks project; summary table with Scalar, VectorT, Vector256Sum, Tensor; columns Mean, Ratio, Code Size; N = 1000000
Figure 7.7. Comparing scalar and vector sums in BenchmarkDotNet
The machine code is written to the BenchmarkDotNet.Artifacts/results/SumBenchmarks-asm.md file (Fig. 7.8). In the scalar method, the loop contains the vaddss instruction (adding one float; the ss suffix means scalar single), and in the vector method, vaddps ymm6, ymm6, [r8] (8 numbers in a YMM register; the ps suffix means packed single). This is how you check whether the JIT really generated vector instructions and whether array bounds checks remain (cmp and a jump to CORINFO_HELP_RNGCHKFAIL).
Screenshot
Rider: open BenchmarkDotNet.Artifacts/results/SumBenchmarks-asm.md, Markdown preview; the Scalar method with vaddss and the Vector256Sum method with vaddps ymm6,ymm6,[...]
Figure 7.8. Assembly code of a vector loop
For a general analysis of limitations, the Roofline model is used: a plot of performance (GFLOPS) against arithmetic intensity (operations per byte of data moved). The sloped part of the “roof” is the memory bandwidth limit, and the horizontal part is the compute limit; for C, C++, and Fortran, Intel Advisor builds it https://www.intel.com/content/www/us/en/developer/articles/guide/intel-advisor-roofline.html.
float and double errors
Vectorization changes the order of floating-point operations, so the result can differ from the scalar one. In the “Array element sum” example, the sum of a million random float values is 499,848.3 in the scalar loop, 499,854.9 in the vector ones, and 499,855.1 in TensorPrimitives, while the exact value (in double) is 499,854.3. The float type holds only 6–7 significant digits, and the error accumulates from millions of roundings. Therefore, sums of many numbers use double, and vector and scalar results are compared with a relative tolerance rather than the == operator. The float type is chosen for graphics, signals, and neural networks: it gives vectors twice as wide.