Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a recurring theme when developers first dive into high-performance Rust: they assume that to use SIMD (Single Instruction, Multiple Data), they have to abandon the comfort of the language and start writing architecture-specific intrinsics like _mm256_add_epi32. They think SIMD is a "dark art" reserved for people who have the Intel 64 and IA-32 Architectures Software Developer's Manual memorized.

The "Intrinsics or Bust" Fallacy

The misconception is that manual, platform-specific assembly or intrinsics are the only way to get a meaningful speedup. In reality, if you write a simple loop to add two slices of numbers, the LLVM backend in Rust is often smart enough to "auto-vectorize" that code. It sees the pattern and emits SIMD instructions for you without you ever asking.

Take this snippet. You might think it's just a standard scalar loop:

fn sum_everything(data: &[i32]) -> i32 {
    data.iter().sum()
}

If you compile this with -C target-cpu=native, the compiler doesn't just add one number at a time. It will likely use AVX or SSE registers to add 4, 8, or 16 numbers in a single clock cycle. If you're relying solely on auto-vectorization, you're basically hoping the compiler is smart enough to recognize your intent. Most of the time it is, but for complex logic—like conditional branching inside a loop—the compiler often gives up and falls back to slow, scalar execution.

Leveraging std::simd for Platform-Agnostic Parallelism

This is where std::simd (the portable SIMD project) comes in. Instead of writing code that only works on an x86_64 machine with AVX2, you can use portable SIMD types. I prefer this approach because it keeps the code readable while giving you an explicit guarantee that the operation is vectorized.

Imagine we are processing a raw buffer of image pixels (u8) and we want to increase the brightness of the image by adding a constant value to every pixel. If we do this naively, we'd loop through every single byte. With portable SIMD, we can process chunks of pixels simultaneously.

#![feature(portable_simd)]
use std::simd::u8x32; // A SIMD vector of 32 unsigned 8-bit integers

pub fn brighten_image(pixels: &mut [u8], offset: u8) {
    let (prefix, middle, suffix) = pixels.as_simd_mut::<32>();
    let offset_vec = u8x32::splat(offset);

    // Handle the bulk of the data in 32-byte chunks
    for chunk in middle {
        *chunk = *chunk + offset_vec; 
        // Note: this wraps around by default in Rust for u8
    }

    // We still have to handle the edges manually
    for p in prefix.iter_mut().chain(suffix.iter_mut()) {
        *p = p.wrapping_add(offset);
    }
}

By using u8x32, we've told the compiler: "I want to treat this data as a block of 32 bytes." On a machine with AVX-256, this maps directly to a single instruction. On an ARM machine with NEON, it maps to the equivalent available width. I love this because the intent is explicit, but the implementation remains portable.

Dealing with the "Tail" of Your Data

One thing that often trips people up is the "remainder" problem. SIMD works in fixed widths—say, 32 bytes. But your image buffer might be 1,027 bytes long. You can't fit that last 3 bytes into a 32-byte register without reading past the end of your allocated memory, which would be a catastrophic security flaw or a crash.

In the example above, I used as_simd_mut. This is a lifesaver. It splits your slice into three parts: a prefix (the odd bits at the start), the middle (the perfectly aligned chunks), and a suffix (the leftover bits at the end). Never ignore the prefix and suffix; if you do, you'll either miss data or trigger a panic. I usually handle the "tails" with a simple scalar loop, as the performance hit on a few bytes is negligible compared to the gain on the rest of the buffer.




📋 Practical Task

Implementing a SIMD-Accelerated Saturated Addition Filter

In image processing, a "wrapping add" (where 255 + 1 becomes 0) creates ugly black artifacts. We want saturated addition: if the result is greater than 255, it should stay at 255.

Your task is to modify the brightness function. Instead of using the default + operator (which wraps), use the saturating_add method available on SIMD vectors to ensure pixels never wrap around.

Requirements:

  • Use #![feature(portable_simd)].
  • Use u8x16 or u8x32 for the vector width.
  • Implement the logic using as_simd to handle the bulk of the data.
  • Ensure the prefix and suffix are also processed using scalar saturating_add.
  • Create a test case with a buffer containing [250, 255, 10, 5] and an offset of 10; the result should be [255, 255, 20, 15].
Rating
0 0

There are no comments for now.

to be the first to leave a comment.