Python, C, Assembly – Faster Cosine Similarity
ashvardanian.com
ashvardanian.com
I should benchmark https://github.com/ashvardanian/SimSIMD/blob/main/include/si... and see if it's any faster than what hnswlib currently uses for bruteforce IP search because I'm currently using hnswlib in something and it's become a bottleneck. Or maybe I'll just try usearch directly.
We have an open issue about it in USearch and a related one in SimSIMD itself, so if you have any suggestions, please share your insights - they would impact millions of devices using the library (directly on servers and mobile, and through projects like ClickHouse and some of the Google repos): https://github.com/unum-cloud/usearch/issues/320
Floats were designed to be mostly-reliable within a mostly-reasonable range of numbers for the kinds of systems we deal with in our scale of the universe. The scale probed here is far far beyond Plank-scale if dealing with metric measurements. We have the converse issue (even with 64bit) at large scales. I could make a geometric vector in space from here to a rock on a planet 15 billion light years away, and another vector to an adjacent rock. I bet those would have a nondeterministic cosine issue too.
I guess a solution is FP 128, or beyond.
As for the 64-bit version, its harder, as the higher-precision `rsqrt` approximations are only available with "AVX512ER". I'm not sure which CPUs support that, but its not available on Sapphire Rapids.
In the current final code, each loop has 3 independent FMAs. And in each loop iteration, the 3 FMAs are dependent on the FMAs from the previous iteration.
So one thing that could speed up the computation is to do 6 independent FMAs per loop iteration instead of 3. This can be done as follow. Instead of doing cumulative sums from 0 to n-1, one could split the computation of the cumulative sums from 0 to n/2-1 and from n/2 to n-1. So the main loop goes only from 0 to n/2-1, and at the k-th loop iteration, we do the 2x3 FMAs corresponding the entries of indices k and n/2+k. And at the end we add the cumulative sums.
If the lack of registers is really the bottleneck, a variant might to use both zmm registers for some cumulative sums and ymm registers for some others. In this case the speed up might be less spectacular though.
Edit: actually, I just discovered that zmm registers overlap the ymm registers, so the only registers left are the ones from the FPU.
Looks like you're missing a square root in the zip-Python version. Does that affect runtime?
Not really, plenty of other ones offer similar capabilities.
> Unlike C++, however, C doesn’t support “generics” or “template functions”.
It does support a light version of them via _Generic.
OK not totally different but different enough: you cannot just write a fuction with a type parameter and expect it to work with multiple different types in C as you can in C++.
It looks like they shoved numpy arrays into the place native Python containers used to occupy. They incurred more of a slowdown than you might naively have expected from that particular obvious error.
Unrelated to this article, numpy is often a lot slower than expected even when used "correctly." Major culprits include order of operations (compared to the ideal / forced by the API / when you think vectorization automagically makes a bad order of operations fast), allocations, memory bandwidth, kernels optimized for a dimension other than the one you care about, kernels optimized for a use case other than the one you care about, and improper installation (appropriately linking fast BLAS/LAPACK objects).
Most of those sins are rectifiable without any major change in coding habits by wrapping your nightmares in `jax.jit` or `torch.compile`, but if your devs are writing the sort of code that makes those things _necessary_ (rather than just nice-to-haves) then that probably still won't solve your problems.
dot::{+/xy};mag::{(+/xx)^0.5};cossim::{dot(x;y)%mag(x)*mag(y)};cd::{1-cossim(x;y)}
https://github.com/briangu/klongpy/blob/main/examples/ml/cos...
We currently support several techniques if you want to provide custom distance functions to USearch, instead of using the default SimSIMD variants. At this point its Numba, Cppyy, and PeachPy: https://unum-cloud.github.io/docs/usearch/python/index.html
Maybe KlongPy can be the next one ;)
Couple of things i noticed. ``` # calculating dot product
a = np.random.randn(1,1536).astype("float32") b = np.random.randn(1536, 1).astype("float32")
result = np.matmul(a * b) ~1.1 microsecond ```
My system configurations are: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz 2.30 GHz, (quad core) AVX256 instructions are supported.
problem lies in calculating the magnitude of vectors, which i guess scipy is doing as ``np.sum(np.square(a)``.Any numpy routine like ``matmul`` using Blas library would be very very fast, but all other routines are just simple C for loops. Magnitude calculation by passing numpy buffer to a C extension takes about ``1.4`` microseconds. using SIMD instructions for magnitude calculation is very straightforward and i get a speed up of about 8x.
For critical calculations you are almost always better off by passing numpy buffer to C code and fusing operations there, and optionally speed up code using SIMD instructions based on hardware you want to target.
It should be ``scipy.spatial.distance.cosine``, not ``scipy.distance.spatial.cosine`` in your blog !
https://ashvardanian.com/posts/python-c-assembly-comparison/...
from math import sqrt, hypot
from operator import mul
def cosine_distance(a, b):
dot_product = sum(map(mul, a, b))
magnitude_a = hypot(*a)
magnitude_b = hypot(*b)
cosine_similarity = dot_product / (magnitude_a * magnitude_b)
return 1 - cosine_similarity
Also check sumprod introduced in the math module from 3.12 onwards.1- Going from correct to fast is simple but the other direction is full of misery.
2- Optimizations are super super input and hardware specific.
2 - Also true, but the SimSIMD library covers the absolute majority of mobile, desktop, and server CPUs produced in the last years.
Does that mean the example above this sentence is fixed, or does it mean it is wrong?
PS: Even though it wasn't intended in that specific case, square roots are often avoided in vector search, most commonly in Euclidean distance computations. Even without the square root, the triangle inequality holds, you can use it to find the closest object, it's just you don't know how close it is :)
> AlphaTensor discovered algorithms that outperform the state-of-the-art complexity for many matrix sizes. Particularly relevant is the case of 4 × 4 matrices in a finite field, where AlphaTensor’s algorithm improves on Strassen’s two-level algorithm for the first time, to our knowledge, since its discovery 50 years ago.
https://towardsdatascience.com/how-deepmind-discovered-new-w...
The code would be clearer as a do { } while(n); instead--it's just a bottom-tested loop, which isn't that uncommon. (I guess the author may just be unfamiliar with do/while loops?)
while (1) {
...
if (!n) {
...
return 1 - ab * rsqrt_a2 * rsqrt_b2;
}
}
Also, the "So let’s use the common Pythonic zip idiom:" version is missing the sqrt calls so doesn't give the same answer as the first version.It’s more likely that compilers generate better autovectorised code with a goto, even though they’re semantically the same.
You don't have to think them unfamiliar to recommend it. Sometimes very skilled people can forget to apply a particular style of control flow, even if they're otherwise experts - everybody makes mistakes.
As for `do`/`while` keywords, its just a preference, but feel free to recommend more patches - I'm always happy to see new contributors - it's a luxury in low-level projects :)
No, it's not. Most compiler IRs will work on a basic block representation, where the compiler will reduce all control flow to unconditional and conditional jumps. If they're not working on that level, then they likely have some form of structure control flow, where autovectorization will ignore gotos entirely because they're the definition of unstructured control flow, so they won't notice when the goto forms a loop that could be autovectorized.
Short story, unless you are doing trivial data-parallel operations, like in SimSIMD, compilers are practically useless. As a proof, I wrote what is now the StringZilla library (https://github.com/ashvardanian/stringzilla) and we've spent weeks with an Intel team, tuning the compiler, no result. So if you are processing a lot of strings, or variable-length coded data, like compression/decompression, hand-written SIMD kernels are pretty much unbeatable.
[1]: https://github.com/ashvardanian/SimSIMD/blob/f8ff727dcddcd14...
EDIT: I tested it myself, also using ChatGPT to code it for me.
>>> can you write a cosine distance function using numpy?
def cosine_distance(vector1, vector2):
dot_product = np.dot(vector1, vector2)
norm_vector1 = np.linalg.norm(vector1)
norm_vector2 = np.linalg.norm(vector2)
cosine_similarity = dot_product / (norm_vector1 * norm_vector2)
cosine_distance = 1 - cosine_similarity
return cosine_distance
The original list one takes 161us on my machine, passing a Numpy array to the original function takes 750us, and using the native Numpy function takes 6.24us.Using Numpy correctly makes it >100x faster!
1 - np.dot(a,b) / (np.linalg.norm(a) * np.linalg.norm(b))