BLAS-level CPU Performance in 100 Lines of C
cs.stanford.edu
cs.stanford.edu
To be more precise: you can try to figure out how cache size would affect optimal block size in theory and in practice this gets you pretty decent performance. But to get the last bit of performance out of the hardware you need to benchmark several configurations on the hardware and select the best one based on run time parameters like u-arch and cache size.
My implementation ended up being about 10 lines of code and ran slightly faster (!!!) than the LAPACK function. (I think this is because LAPACK provided numerical stability for special cases which didn't apply to my use case.)
- the gains are the sort of typical 2-8x speed improvements from vectorization, not the multiple-orders-of-magnitude gains that you can get on dense GEMM.
- the absolute number of flops performed is O(n^2) rather than O(n^3) for GEMM, so even if you could make tridiagonal operations infinitely fast, that optimization effort would be better spent on even small speedups to the O(n^3) work that probably comprises other parts of your algorithm.
https://github.com/HazyResearch/blocking-tutorial/blob/maste...
Where they implement the matrix inner product.
And their example implementations using that start at
https://github.com/HazyResearch/blocking-tutorial/blob/maste...
The inner multiply is all AVX2(w/FMA extension) so it's probably making some degree of LoC win from not needing to handle hardware capabilities or being portable, but still it is impressive.
It is also worth noting that the inner product is c++ templates - it could probably be turned into a horrific macro to make it pure C, but I'd say it isn't worth it. But then I prefer C++ to C :D
BLAS-level performance in C means you write ISO C, and the compiler spits out the required instructions.
That's not the real problem with level 3 BLAS, though, it's taking account of the micro-architecture and matrix dimensions with appropriate blocking and packing.
You can get >80% of hand-optimized performance with plain C [1]. Leaving pure C, you should be able to improve that with prefetching, and I should try to figure why that didn't work in my tests.
1. https://fx.srht.site/posts/2022-01-03-pure-c-gemm.html
Edit: You probably want -funroll-loops too.
Please don't do this if you want your results to be reproducible.
At least some of BLAS was defined before there even was an IEEE floating point standard.
And for what gain, just to make it simpler for your tests to compare against a fixed reference? In practice you don't gain anything else by having reproducible calculations because your inputs will have a lot more uncertainty in them than just beyond the 16th digit.
Better to embrace the difference in results between hardware architecture, parallel execution, compiler version, optimization level, etc. This means spending time making your test suite more robust to small variations, but it saves you time investigating failed test runs that are insignificant. And as a bonus, it forces you to understand the numerical properties of your code better.
The article even shows that - look at the C++ code - no instrisics in sight, just pure basic C++ getting the optimisation and performance from the compiler alone.
However, you can learn the basics in an hour, and read/write small amounts of code (like in the article) very quickly. The trick that you don't learn all those functions, instead you constantly use the amazing interactive guide:
https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
I guess real pros do memorize the API of course, but I have written many (<1000) lines of intrinsics code like this.
https://www.intel.com/content/dam/develop/public/us/en/inclu...
The rest are complex and they become even more complex when one tries to implement them in a way which compile efficiently on multiple CPUs (from different caches to different architectures).
Interesting article but the authors' conclusion is completely misguided.
My Google-fu has failed me, however.
When you realize that most deep learning library, "einsum is all you need", and that einsum is just loops and sum, it kind of screams you are doing it the wrong way when using numerical libraries ranging from BLAS to cuda.
Loops can also be automatically differentiated. Loops can also be reordered. Loops can be tiled. There are some techniques (polyhedral loop optimization) to do it automatically. Loops can be parallelized and vectorized.
Most of the optimization are always the same and due to the memory hierarchy. The problem space is well defined. Candidates solutions can be benchmarked automatically. Some architectures like to have their memory access in a Structures of Arrays, other in Array of Structures, or even Arrays of structures of Arrays. Benchmark depend of sizes in various dimensions. Are you really always going to write the optimizations manually, and do the complex code transforms by hand.
This feels like one area ripped for disruption. The other day there was this thread about not writing your own computer language, but here one domain specific language can have a huge impact. Take the naive form and automatically generate the forward, backward, (nth order autodiff) optimized version given an instruction set whether for cpu, gpu or tpu, and for various memory usage profile.
Optimizing code, even though it feels quite fun and rewarding to do manually is a job for compilers.
Write naive algorithms but smart compilers.
Extra kudos if you handle the mathematical function : For example if you want an exponential : write it as an infinite taylor sum. If you want matrix exponential write it as a taylor sum also ( and if your compiler automatically derive the Pade approximation for improved numerical stability you know you are on the right track).
> Write naive algorithms but smart compilers.
I'm not sure where you're coming from here. The article's charts show that in this case, naive code gets you less than 2% the performance of an optimised implementation.
That's not what the chart shows at all. The charts shows what non optimized code compiles to.
Just run the code through any optimizing compiler released in the past decade and you'll find that do the vectorization for you. No need to use non-portable intrinsics.
Even the block optimisation can be done in plain C.
While optimised algorithms are better than naive algorithms, optimising machine code is indeed a compiler task. Some languages tend to do a better job of describing the algorithm than others, but that's a different can worms entirely.
In theory the GCC is capable to do all these optimizations, even loop tiling, vectorization and autopar as it indeed has a polyhedral representation of loops. In practice it might be another story of course.
That doesn't matter. You have to specify the target platform as well.
Just take the naive code and specify -O3 in godbolt.org for example.
Assembly exerpt of the inner loop:
L5:
movss xmm0, DWORD PTR [rax]
mulss xmm0, DWORD PTR [rdx]
add rax, 4
add rdx, rdi
addss xmm1, xmm0
movss DWORD PTR [rcx], xmm1
cmp rsi, rax
jne .L5
No AVX in sight.Now specify -mavx2 -mfma and compare the inner loop:
.L5:
vmovss xmm2, DWORD PTR [rax]
vfmadd231ss xmm0, xmm2, DWORD PTR [rdx]
add rax, 4
add rdx, rdi
vmovss DWORD PTR [rcx], xmm0
cmp rsi, rax
jne .L5
Case in point: without providing the compiler flags used for compilation, charts are misleading at best and outright wrong at worst. OMP_NUM_THREADS=8 g++ -Wall -std=c++11 -fopenmp -D_USE_OPENBLAS -O3 -L/usr/lib -lopenblas -I /usr/include/openblas -march=native -mavx2 -mfma -funroll-loops compare_blas.cpp -lopenblas -o matmul
Of course it is possible that different flags were used to benchmark the naive version.edit: and of course those f*ss suffixes show that the compiler is not actually vectorizing the loops, just using the avx encoding of scalar instructions (plus fma).
edit2: likely -ffast-math is also required for vectorization as it will change the sequence of operations, but after experimenting with it a bit it seems that in this case is still not sufficient.
This will future-proof your code and make it more portable, not to mention instill confidence that your code is correct. Of course if you are just playing around with a toy project, this is probably not worth the effort.
I don't know what you're smoking, but I want some of it.
Right, if we forget about performance a three-nested-loop GEMM is certainly better than any other implementation. But that's completely meaningless and you could find such examples for almost any task. This is almost as saying writing hash functions is easy as long as you forget about collisions and avalanche effects.
> When you realize that most deep learning library, "einsum is all you need", and that einsum is just loops and sum, it kind of screams you are doing it the wrong way when using numerical libraries ranging from BLAS to cuda.
That is just not true: It is correct that deep learning tasks mainly reduce to convolutions and GEMM. Convolutions can be mapped to contraction kernels which again can be mapped to GEMM. However, Numpy's einsum is a great example how you shouldn't do it as it's notoriously slow. Also, the main task in general tensor contraction functions is to find an optimal contraction ordering rather than executing GEMM (you can have exponential savings if you choose a good contraction sequence). You can read more about this in e.g. https://arxiv.org/abs/2002.01935.
> Loops can also be automatically differentiated. Loops can also be reordered. Loops can be tiled. There are some techniques (polyhedral loop optimization) to do it automatically. Loops can be parallelized and vectorized.
This is true in theory and while modern compilers get better and better there is still no compiler framework that could, given an arbitrary linear algebra task outperform a manually optimized BLAS or LAPACK library.
Also, you are mentioning just a very few low-level optimization techniques that can be applied while modern compiler research currently focuses on different aspects as implemented in XLA for example.
> Optimizing code, even though it feels quite fun and rewarding to do manually is a job for compilers. > Write naive algorithms but smart compilers.
Still, don't write bad code and use compilers as an excuse.
Yes, "we just needs a sufficiently advanced compiler" is a meme, but 1) not everyone knows that and 2) that's not what this comment is asking for:
> but here one domain specific language can have a huge impact
There's a big difference between a sufficiently advanced compiler for general C code, and a compiler for a domain-specific array processing language, for example.
Have a look at halide: https://halide-lang.org , where you specify the computation to perform and a schedule (loop order, blocking, vectorisation) separately. There's even an automatic scheduler that can find a good schedule given some example array sizes and parameters.
The fftw paper is worth a read, too -- this shows that for a restricted domain it's possible to automatically find code that works as well as or better than hand-tuned algorithms. Generalising this is of course very hard.
For deep learning I think a lot of this is already done, see XLA for example.
They reduce the ninja gap for a range of benchmarks from 24x to 1.3x by relying on "smart" compilers and using only basic program transformations---no intrinsics!
1. https://github.com/flame/how-to-optimize-gemm/tree/master/sr...
While it is good to understand the techniques to make it faster, for most cases using BLAS is probably better
using LoopVectorization
function mul!(C,A,B)
@tturbo for i in 1:size(C,1)
for j in 1:size(C,2)
C[i,j] = zero(eltype(C))
for k in 1:size(A,1)
C[i,j] += A[i,k]*B[k,j]
end
end
end
return C
end