How do I obtain the theoretical/Linpack FLOPS performance on basic vector/matrix operations?

Viewed 55

The question is simple. How do I further optimize my code as the basic matrix operations are critical and common to my calculation. BLAS and LAPACK operations are good in linear algebra but neither of them provides basic element by element addition/multiply operations (Hadamard). Theoretical performance maybe difficult, but Linpack performance or 60~80% Linpack performance should be achievable. (I can only do 12%, if I use multiply-add, then only 25%)
For references
Theoretical performance: 8259u has 4 cores * 3.8GHz * 16 FLOPS = 240 GFlops
Linpack performance: 8259u can run as fast as 140~160 GFlops double precision operations.

Platform: Macbook Pro 2018, Monterey
CPU: i5-8259u, 4c8t
RAM: 8GB
CC: gcc 11.3.0
CFLAGS: -mavx2 -mfma -fopenmp -O3

Here's my attempt
the flops are calculated as follows:

double time = stop - start;
double ops = 1.0 * Nx * Ny * iterNum; //2.0 for complex numbers
double flops = ops / time;
double gFlops = flops / 1E9;

Here's some results when I run my code. real and complex results are almost the same. Only showing the real results (roughly):

//Nx = Ny = 2048, iterNum = 10000
//Typical matrix size and iteration depth for my calculation
threads =    1:  1 GFlops
threads =    2:  2 GFlops
threads =    4:  3 GFlops
threads =    8:  4 GFlops
threads =   16:  9 GFlops
threads =   32: 11 GFlops
threads =   64: 15 GFlops
threads =  128: 18 GFlops
threads =  256: 19 GFlops
threads =  512: 21 GFlops
threads = 1024: 20 GFlops
threads = 2048: 40 GFlops // wrong answer

For the convenience of large matrix on heap and integrating with mathGL, the matrix is flattened as a vector consisting of Nx * Ny elements cascading by rows.

// for real numbers
  x = (double *)_mm_malloc(Nx * Ny * sizeof(double), 32);
  y = (double *)_mm_malloc(Nx * Ny * sizeof(double), 32);
  z = (double *)_mm_malloc(Nx * Ny * sizeof(double), 32);
sum = (double *)_mm_malloc(Nx * Ny * sizeof(double), 32);
// for complex numbers
  x = (double *)_mm_malloc(Nx * Ny * sizeof(double complex), 32);
  y = (double *)_mm_malloc(Nx * Ny * sizeof(double complex), 32);
  z = (double *)_mm_malloc(Nx * Ny * sizeof(double complex), 32);
sum = (double *)_mm_malloc(Nx * Ny * sizeof(double complex), 32);

and the addition was done parallelly using openmp.

    double start = omp_get_wtime();

    #pragma omp parallel private(shift)
    {
        for (int tds = omp_get_thread_num(); tds < threads; tds = tds + threads)
        {
            shift = Nx * Ny / threads * tds;
            for (int i = 0; i < iterNum; i++)
            {
                AddComplex(sum+shift, sum+shift, z+shift, Nx/threads, Ny);
            }
        }
    }

    double stop = omp_get_wtime();

I wrote explicit vectorization code using AVX intrinsics "immintrin.h".

//real matrix addition
void AddReal(double *summation, const double *summand, const double *addend, int Nx, int Ny)
{
    int nBlock = Nx * Ny / realPackSize;
    int nRem = Nx * Ny % realPackSize;

    register __m256d packSummand, packAddend, packSum;
    const double *px = summand;
    const double *py = addend;
    double *pSum = summation;
    
    for (int i = 0; i < nBlock; i++)
    {
        packSummand = _mm256_load_pd(px);
        packAddend = _mm256_load_pd(py);
        packSum = _mm256_add_pd(packSummand, packAddend);
        _mm256_store_pd(pSum, packSum);
        px = px + realPackSize;
        py = py + realPackSize;
        pSum = pSum + realPackSize;
    }
    for (int i = 0; i < nRem; i++)
    {
        pSum[i] = px[i] + py[i];
    }

    px = NULL;
    py = NULL;
    pSum = NULL;

    return;
}
//Complex matrix addition
void AddComplex(double complex *summation, const double complex *summand, const double complex *addend, int Nx, int Ny)
{
    int nBlock = Nx * Ny / complexPackSize;
    int nRem = Nx * Ny % complexPackSize;

    register __m256d packSummand, packAddend, packSum;
    const double complex *px = summand;
    const double complex *py = addend;
    double complex *pSum = summation;

    for (int i = 0; i < nBlock; i++)
    {
        packSummand = _mm256_load_pd(px);
        packAddend = _mm256_load_pd(py);
        packSum = _mm256_add_pd(packSummand, packAddend);
        _mm256_store_pd(pSum, packSum);
        px = px + complexPackSize;
        py = py + complexPackSize;
        pSum = pSum + complexPackSize;
    }
    for (int i = 0; i < nRem; i++)
    {
        pSum[i] = px[i] + py[i];
    }
    
    px = NULL;
    py = NULL;
    pSum = NULL;

    return;
}
1 Answers

Level 1 (eg. dot product) and level 2 (eg. vector-matrix multiplication) BLAS functions are known not to scale (especially level 1 BLAS functions) as opposed to level 3 (eg. matrix-multiplication). Indeed, they are generally memory-bound: the amount of data read/written is O(n) while the amount of floating-point operation is also O(n). This is not the case for level 3 BLAS which are generally clearly compute-bound.

Theoretical performance maybe difficult, but Linpack performance or 60~80% Linpack performance should be achievable

If the computation is memory bound, then, no, this is not possible. Linpack is generally clearly compute bound on nearly all machine. The think is memory is slow and the speed of the RAM is not increasing as fast as the speed of processors over the last decades. This is known as a memory wall (formulated few decades ago and still true nowadays).

Here's some results when I run my code.

Having a faster computation with from using 1024 threads instead of 512 on a mobile processor with 4 core and 8 thread make me think that there is a huge problem somewhere. The maximum should be reached with 8 threads, or otherwise this means the computation is clearly inefficient. Indeed, running more threads than hardware threads cause the OS scheduler to make expensive context-switch (higher overhead). In the end, your processor never runs more that 8 tasks at a time. There are two possibility:

  • The timings are not correct (the provided piece of code about that seems fine to me)
  • The program is bogus
  • The computation exhibit a super-linear speed up (possibly due to cache)

I wrote explicit vectorization code using AVX intrinsics "immintrin.h".

The hot loop contains 2 loads, 1 store, 1 add and few instructions incrementing integers. Your processor can do 2 loads and 1 store per cycle so the SIMD part can be done in 1 cycle of throughput (though the latency can be much bigger) assuming nBlock is large enough.

Your processor can do 2 add per cycle so half the throughput is lost. However, you cannot write something faster than that if the load/write are mandatory.

If complexPackSize is smaller than a SIMD lane, then I think the processor has to make complex operation due to the overlapp with the past iteration that will certainly make it run the loop much less efficiently (a loop carried dependency will make the loop latency bound which is very inefficient here). If complexPackSize is much larger than a cache line, then prefetching will likely be an issue.

Your processor cannot execute too many instructions at the same time. The increment instruction and the loop check cause 5 instruction to be executed, which consume at least 1 cycle. This reduce the throughput by a factor of 2 again so not more than 25% of the theoretical performance can be reached. This can be improved a bit by unrolling the loop. Unrolling might also improve the execution because the _mm256_add_pd instruction has a pretty high latency. One should keep in mind that SIMD instructions are great for throughput but not for latency. Thus, when the latency is not an issue, SIMD codes should be fast.

Note that the write allocate cache policy cause data to be read when _mm256_store_pd is used increasing the amount of data transferred from the RAM and reducing the observed throughput. _mm256_stream_pd can be used to avoid this effect but it is fast only if data are not read just after or when data do not fit in the cache anyway. It also require data to be aligned. In fact, _mm256_store_pd also requires that and if it is not the case, it certainly cause a silent bug. The same applies for _mm256_load_pd: _mm256_loadu_pd should be used instead for unaligned data. I am not sure data read is always aligned. It should be fine if complexPackSize is a power of two divisible by 32 as well as shift. However, I highly doubt this is the case for shift, especially with a large number of threads. I also find very suspicious to use a constant complexPackSize while the SIMD lanes have a fixed size. Did you checked the results in all cases?

Related