Is SIMD worth the hassle?

I've read a little on SIMD packages and how they work, and this article states that loading data into the vectors is slow, and there's a cache, and ideally you should keep the data there, and do minimum loads and maximum operations.

I wonder if this will be an issue on my task: Dijkstra graph traversal problem. For every vertex, I have two vectors: one with target vertices (indices), and another with costs to reach there.

The graph has ~2000 vertices in total. Every vertex has ~20-50 edges from it, and each element is u16, so with 256-bit vectors, the CPU could process 16 values at once.

But when visiting a vertex, I'll have to load these vectors in SIMD. How long it does it take?

The procedures are, when visiting vertex A

  • load the vector of costs for the edges starting from A into SIMD register
  • add the elapsed cost to the vector
  • load the vector of old estimated costs at the target vertices (maybe I can skip this, and just use heap, or even write to the other vector)
  • make a vector of booleans: set to true where the element in new vector is smaller than in the old vector
  • update the vector with estimated costs at target vertices

SIMD only works when you're not memory bound.

If the CPU can predict where you're going to access and preload it to the cache (e.g. when working sequentially), SIMD is going to be 100% effective (assuming there exist instructions that can do what you want). Of course, the speedup depends on a lot of things - what part of the overall program time this code takes, the throughput and latency of the SIMD instructions versus those of the scalar instructions, etc..

If the accesses are not predictable but are close in memory (so they will likely be in the same cache line, 64/128 bytes - you can also manually preload sometimes), SIMD can speed things up but not to the same amount. And if you're memory bound, SIMD will not affect the speed at all.

Also worth considering, even if the data is close - do you need to handle contiguous 16 values or non-contiguous? Some processors (but not all) have instructions (gather and scatter instructions) for the non-contiguous case as well, but they're much slower.

You may not need to write explicit SIMD. If the data layout and operations are obviously laid out to be SIMD-friendly then the compiler will choose the right instructions for you. This removes the hassle of writing CPU-specific instructions.

You need to make data SIMD-friendly and usable for batch processing. This may need rethinking the algorithm.

Look into SoA data layout (structure of arrays).

If your graph is dense grid, and you're doing Dijkstra and not A*, then it's doable in batches (for a batch of nodes add cost from row above and row below).

Also check out how graph problems are solved in GPU shaders by doing them in reverse. This is a gather-scatter problem. Instead of for each node make writes to neighbors to add a cost (scatter), make each node update itself by gathering a sum of costs from its neighbouring nodes.

I think you missunderstod it. There is no difference AFAIK and in my experience so far writing SIMD (basic SIMD). When you write scalar code, you also load data from cache or RAM. SIMD is same, you load data from cache of RAM. So cache miss cost is same, so design your memory layout become contigous, it is possible I created shortest path algorithm in many contigous Vector (data oriented design) in the past

What is important, do not use multiple SIMD instrinsics call that the total calls in 1 time is > the maximum toral of the target SIMD registers, for illustration, let's say you can only do 8x SIMD operation at a time for data type A

Simd multiply 1
Simd multiply 2
Simd multiply 3
Simd multiply 4
Simd multiply 5
Simd multiply 6
Simd multiply 7
Simd multiply 8

Then you do

Simd reduce

Where no SIMD register is freed, it is all still occupied by previous operation. This will make it use temporary stack memory which is slower compared to SIMD register

In big data, the gap is much bigger (as long as you can design more and more contigous data, because SIMD + cache hit is very nice combination). I haven't tried the non contigous version, I will try it :<

And also avoid building unnecerery instruction dependency

SIMD register is wider than scalar register, so you are mostly always faster than scalar if you can process more total elements at a time than scalar

I tried this in the past, it works for simple operation, but for more complex operation, the resulted SIMD assembly is suboptimal. Not clean (contains unnecerery/redundant operation). And sometimes it does not pick the best instruction that is available

And it is not as simple as arranging the code become contigous. But also making sure there is no branching between (where SIMD instrinsic provides the branchless instrinsic already), and no dependency like the current operation depends on the previous operation

It is easier to use portable SIMD, compared to hoping LLVM will auto SIMD the code. But the portable SIMD is still in nightly. So it needs 3rd party library, there are static and dynamic dispatch variants. Static one, the SIMD target is already fixed. Where dynamic one can contains many variants, so it can handle multiple target and lane size at the cost of runtime dispatch. You can manually make the static one to handle multiple lane size with SIMD tiering (branching) but it will not be full width due to it is already optimized for single best SIMD that the CPU has (eg if it is already compiled for AVX512, it can not fallback to AVX2 at full width. But it is best if you don't intend to run the app in different machine). Using portable SIMD also will has auto scalar fallback for CPU that doesn't support SIMD

"Memory bound" means that you're limited by the speed of memory and not operations. So speeding up operations, no matter by how much, including processing multiple elements at once, will cut exactly zero milliseconds from the the total time.

For example, take this code:

let data: &[usize] = ...;

let mut index = 0;
for _ in 0..1_000_000 {
    let d = &data[index..][..256];
    // Do something with `d`...
    index = d.iter().copied().sum::<usize>();
}

Here the code is very memory bound: to get to the next iteration, the CPU must complete the fetch from memory of the previous iteration. The sum to get the index is done in parallel to fetching the next data, and fetching the next data will take much longer, so you cannot optimize this by optimizing the summation - SIMD won't help here. Even if you make the summation take zero milliseconds, the code won't be sped up at all.

Autovectorization is indeed not always optimal, but there are tricks to cause LLVM to emit better SIMD code. Manual vectorizations has its own issues.

That is wrong. Slow RAM access does not make speeding the computation proccess has no effect. Total latency isn't just loading data, but the sum of all of loading data + processing data, so if you make any of them faster, you make the whole thing faster. And CPU has intellegent prefect in an already contigous data, they will be fetched to the cache chunk by chunk with multi tiered cache (L1, L2, L3). They are faster than RAM

And your code has SIMD under the hood, so you don't compare non SIMD with SIMD. To show the actual number, you have to tell LLVM to not auto SIMD with LLVM's no vectorize flag

RUSTFLAGS="-C llvm-args=-vectorize-loops=false" cargo run --release

Your code, I just complete it to make it compiles

use std::time::Instant;

fn main() {
    let data_storage: Vec<usize> = (0..1000).collect();
    let data: &[usize] = &data_storage;

    let start = Instant::now();

    let mut index = 0;
    for _ in 0..1_000_000 {
        let d = &data[index..][..256];
        let total = d.iter().copied().sum::<usize>();
        index = total % (data.len() - 256);
    }

    let elapsed = start.elapsed().as_millis();

    println!("{index}");
    println!("Elapsed time: {} ms", elapsed);
}

The result :

With SIMD. I didn't use flag target-cpu=native because Neon is already the best SIMD in my phone, and Rust already pick Neon by default. In x86 CPU that supports AVX512, you have to put the target native flag to not make Rust use general available x86 SIMD (SSE2) but to use the best available one that the CPU has (for example AVX512)

[root@localhost tes2]# cargo run --release                                 Finished `release` profile [optimized] target(s) in 0.07s
     Running `target/release/tes2`                                     0
Elapsed time: 113 ms
[root@localhost tes2]# cargo run --release
    Finished `release` profile [optimized] target(s) in 0.08s
     Running `target/release/tes2`
0                                                                      
Elapsed time: 114 ms
[root@localhost tes2]# cargo run --release
    Finished `release` profile [optimized] target(s) in 0.09s
     Running `target/release/tes2`
0
Elapsed time: 115 ms           

Without SIMD, almost 3x slower, for a simple operation not even complex yet. And only use Neon instruction, not even the big x86 AVX512

[root@localhost tes2]# RUSTFLAGS="-C llvm-args=-vectorize-loops=false" cargo run --release
    Finished `release` profile [optimized] target(s) in 0.09s
     Running `target/release/tes2`
0
Elapsed time: 268 ms                                                   [root@localhost tes2]# RUSTFLAGS="-C llvm-args=-vectorize-loops=false" cargo run --release
    Finished `release` profile [optimized] target(s) in 0.07s
     Running `target/release/tes2`
0                                                                      
Elapsed time: 270 ms
[root@localhost tes2]# RUSTFLAGS="-C llvm-args=-vectorize-loops=false" cargo run --release
    Finished `release` profile [optimized] target(s) in 0.08s
     Running `target/release/tes2`
0                                                                      
Elapsed time: 271 ms
[root@localhost tes2]#

Even better if you change the data type of your code from usize to smaller one that still fit the size. I changed it to u32. The SIMD becomes 2x faster thsn before, while the non SIMD stays in its previous speed. So in total, the SIMD is almost 5x faster

use std::time::Instant;

fn main() {
    let data_storage: Vec<u32> = (0..1000).collect();
    let data: &[u32] = &data_storage;

    let start = Instant::now();

    let mut index = 0u32;
    for _ in 0..1_000_000 {
        let d = &data[index as usize..][..256];
        let total = d.iter().copied().sum::<u32>();
        index = total % (data.len() as u32 - 256);
    }

    let elapsed = start.elapsed().as_millis();

    println!("{index}");
    println!("Elapsed time: {} ms", elapsed);
}

With SIMD :

Without SIMD :

What is the trick to produce equal or better SIMD assembly than SIMD instrinsic for this simple operation, just searching byte in many bytes. I used that case when learning SIMD that auto SIMD and even the nightly portable SIMD couldn't produce equal SIMD assembly quality to manual SIMD intrinsic with std::arch

In the most strict sense, perhaps, but for any practical purpose pipelined computation makes that not true at all.

If it takes 10ms to fetch the entire dataset into memory and 1ms to process, the total time is not 11ms. It's 10ms + whatever internal latency there is for the last result, eg in the microsecond scale.

I respect how you're trying to back your point with empirical bench result, but this benchmark just isn't written properly. Your data_storage is so small for today's computers that they easily fit in fastest CPU cache. Right now the data is (0..1000) and repeat round 0..1_000_000. Try data_storage = (0..10_000_000) for _ in 0..100 and you'll get different results. Maybe try various other sizes to obtain a fancy line graph benchmark nerds love[1].


  1. Be careful with increasing the data count any further: we're really close to overflowing an u32. ↩︎

Heck even 40MB is pretty feasible for L3 now...

You are confusing about intruction level paralelism and data level paralelism. SIMD is data level, and it is way fewer total CPU cycle for the same amount of data

I never said the memory fetch is sequential, I clearly mentioned "prefetch". Prefect is CPU fetching next data for next operation in the bsckground. I mentioned the proccessing can't be exactly paralel to the memory fetch, because can't process if the data isn't ready yet, then when the data is ready the SIMD finish earlier, making the entire total is SIMD faster. Maybe you can show example where SIMD is slower

So now people already understand more that the cache, memory prefetch, etc is not bad that it is easy to say it is memory bound

Here the result, SIMD is still faster more than 2x (in Neon, not even the wide AVX512)

SIMD :

Without SIMD :

All of that is completely irrelevant for what I quoted. Being memory-bound means, by definition, your execution time is completely dominated by memory access time, and any per instruction latency is irrelevant.

The entire point is that when you're memory-bound speeding up individual instructions does precisely nothing: you need to change your memory access to reduce the time the CPU just spends waiting for more data; either by just reducing the amount of memory used or by making better use of already cached data.

(This isn't to say anything about if this problem is memory bound or not, I've not looked at that at all)

And I show you that that is wrong. The SIMD is always faster because if you say SIMD's one has memory fetch cost, so is the non SIMD because it is not like the non SIMD suddently does not fetch from memory. But because the SIMD finish execution faster, there is always gap

My phone CPU is ARM Cortex A55 and A75, which has cache size as below

Cortex A55 :
L1 data cache = 16 - 64KB
L2 = 64 - 256KB
L3 = 512KB - 4MB

Cortex A75 :
L1 data cache = 64KB
L2 = 256KB - 512KB
L3 = 1MB - 4MB

In this benchmark, there are 10.000.000 of u32. The size of u32 is 4 byte. So the total size of the data is 40.000.000, equal to around 38MB which way out of the total size of the cache. So they are outside of the cache, they are fetched chunk by chunk. But the SIMD is faster than the non SIMD, because both has the same cost but the SIMD finish its processing faster after the data is ready

The code :

use std::time::Instant;

fn main() {
    let data_storage: Vec<u32> = (0..10_000_000).collect();
    let data: &[u32] = &data_storage;

    let start = Instant::now();

    let mut index = 0u32;
    for _ in 0..1_000_000 {
        let d = &data[index as usize..][..256];
        let total = d.iter().copied().sum::<u32>();
        index = total % (data.len() as u32 - 256);
    }

    let elapsed = start.elapsed().as_millis();

    println!("{index}");
    println!("Elapsed time: {} ms", elapsed);
}

To run the SIMD just

cargo run --release

To show the actual speed of the non SIMD (not by keep using SIMD under the hood)

RUSTFLAGS="-C llvm-args=-vectorize-loops=false" cargo run --release

SIMD result :

Non SIMD result :

And that only in Neon

That cannot be. Logically if it takes time Tm to fetch all the data (and write it back) and time Tp to process it the total time is Tm + Td. If Tm is much greater than Tp then Tp becomes negligible. The minimum possible time to complete the job is Tm when Tp = 0.

Certainly the memory latency can be reduced by cache and processing your data in a cache friendly way. But that breaks down at some point.

You are just realizing your previous assumption is incorrect

CPU prefetchs data by chunk (size of L1)
Data is to big, the first few of it sits in L1, the next in L2, the next one in L3, the next one in RAM. Then CPU will prefetch from RAM gradually

The arrow represents the data movement when CPU processing it

Non SIMD :

Fetch -> compute ->, no wait, fetch -> compute, etc

---->
......---->
............---->

SIMD :

Fetch many -> compute many
----> ---->
----> ---->
----> ---->

If you haven't understand yet, think HTTP 1.1 pipeline and HTTP2 multiplexing. You send multiple requests at same time, eventhough the network is slow, HTTP2 finish earlier (remove packet loss because SIMD doesn't has this). That is same with non SIMD and SIMD

Big data doesn't make SIMD bad, but it even shows the SIMD more, because SIMD is designed exactly for this

I increased to 1GB vector, and run it on AVX512 CPU. The SIMD is 5x faster

 let data_storage: Vec<u32> = (0..270_000_000).collect();

No I'm not.

Yes, yes, I am aware of all that.

If you have not understood what I said yet, put your "memory" on a swap file, put that swap file on a network connected share, put that share on Neptune. Now fetching and writing back your 1GB is going to take many hours. Any processing speed up with SIMD or whatever will not help noticeably.

All your result has shown is that your memory system can keep up with the processing nicely. Which is great of course.

I suspect we have started to argue about different things.

If you are doing a lot of operations, what is relevant isn't the latency of single operation, it's the throughput.

Latency of a single operation is the sum of fetch latency + processing latency, but the throughput is max(fetch throughput, processing throughput).

That's not how it works: fetch and compute happen in parallel.

  1. Fetch item 1.
  2. Fetch item 2 and compute on item 1.
  3. Fetch item 3 and compute on item 2.
    Etc.