This is the website for a prior semester's offering of CS 3330.

Changelog:

Introduction

This semester we are experimenting with having students use SIMD (single instruction multiple data) instructions in several assignments. These are sets of instructions that operate on wide registers called vectors. For our assignments, these vectors will be 128 bits wide. Typically, instructions that act on these wide registers will treat it as an array of values. They will then perform operations independently on each value in the array. In hardware, this can be implemented by having multiple ALUs that work in parallel. As a result, although these instructions perform many times more arithmetic than “normal” instructions, they can be as fast as the normal instructions.

Generally, we will be accessing these instructions using “intrinsic functions”. These functions usually correspond directly to a particular assembly instruction. This will allow us to write C code that accesses this special functionality consistently without losing all the benefits of having a C compiler.

Intrinsics reference

The intrinsic functions we will be using are an interface defined by Intel. Consequently, Intel’s documentation, which can be found here is the comprehensive reference for these functions. Note that this documentation includes functions corresponding to instructions which are not supported on lab machines. To avoid seeing these be sure to check only the boxes labelled “SSE” through “SSE4.2” on the side.

Header files

To use the intrinsic functions, you need to include the appropriate header file. For the intrinsics we will be using this is:

#include <immintrin.h>

Representing vectors in C

To represent 128-bit values that might be stored in one of these registers in C, we will use one of the following types:

Since each of these is just a 128-bit value, you can cast between these types if a function you want to use expects the “wrong” type of value. For example, you might want to use a function meant to load floating values to load integers. Internally, the functions that expect these types just manipulate 128-bit values in registers or memory.

Setting and extracting values

If you want to load a constant in a 128-bit value, you need to use one of the intrinisc functions. Most easily, you can use one of the functions whose name starts with _mm_setr. For example:

__m128i values = _mm_setr_epi32(0x1234, 0x2345, 0x3456, 0x4567);

makes values contain 4 32-bit integers, 0x10, 0x20, 0x30, and 0x40. We can then extract each of these integers by doing something like:

int first_value = _mm_extract_epi32(values, 0);
// first_value == 0x1234
int second_value = _mm_extract_epi32(values, 1);
// second_value == 0x2345

Note that one may only pass constant indices to the second argument of _mm_extract_epi32 and similar functions.

Loading and storing values

To load an array of values from memory or store an array of values to memory, we can use the intrinsics starting with _mm_loadu or _mm_storeu:

int arrayA[4];
_mm_storeu_si128((__m128i*) arrayA, values);
// arrayA[0] == 0x1234
// arrayA[1] == 0x2345
// ...

int arrayB[4] = {10, 20, 30, 40};
values = _mm_loadu_si128((__m128i*) arrayB);
// 10 == arrayB[0] == _mm_extract_epi32(values, 0)
// 20 == arrayB[1] == _mm_extract_epi32(values, 1)
// ...

Arithmetic

To actually perform arithmetic on values, there are functions for each of the supported mathematical operations. For example:

__m128i first_values =  _mm_setr_epi32(10, 20, 30, 40);
__m128i second_values = _mm_setr_epi32( 5,  6,  7,  8);
__m128i result_values = _mm_add_epi32(first_values, second_values);
// _mm_extract_epi32(result_values, 0) == 15
// _mm_extract_epi32(result_values, 1) == 26
// _mm_extract_epi32(result_values, 2) == 37
// _mm_extract_epi32(result_values, 3) == 48

Different types of values in vectors

The examples treat the 128-bit values as an array of 4 32-bit integers. There are instructions that treat in many different types of values, including other sized integers or floating point numbers. You can usually tell which type is expected by the presence of a something indicating the type of value in the function names. For example, “epi32” represents “4 32-bit values”. (The name stands for “extended packed integers, 32-bit”.) Some other conventions in names you will see:

Example (in C)

The following two C functions are equivalent

int add_no_SSE(int size, int *first_array, int *second_array) {
    for (int i = 0; i < size; ++i) {
        first_array[i] += second_array[i];
    }
}

int add_SSE(int size, int *first_array, int *second_array) {
    int i = 0;
    for (; i + 4 <= size; i += 4) {
        // load 128-bit chunks of each array
        __m128i first_values = _mm_loadu_si128((__m128i*) &first_array[i]);
        __m128i second_values = _mm_loadu_si128((__m128i*) &second_array[i]);

        // add each pair of 32-bit integers in the 128-bit chunks
        first_values = _mm_add_epi32(first_values, second_values);
        
        // store 128-bit chunk to first array
        _mm_storeu_si128((__m128i*) &first_array[i], first_values);
    }
    // handle left-over
    for (; i < size; ++i) {
        first_array[i] += second_array[i];
    }
}

Selected handy intrinsic functions:

Arithmetic

Load/Store

Set constants

Extract parts of values

Convert between types of values

Rearrange 128-bit values

Example (assembly instruction)

The instruction

    paddd %xmm0, %xmm1

takes in two 128-bit values, one in the register %xmm0, and another in the register %xmm1. Each of these registers are treated as an array of two 64-bit values. Each pair of 64-bit values is added together, and the results are stored in %xmm1.

For example, if %xmm0 contains the 128-bit value (written in hexadecimal):

0x0000 0000 0000 0001 FFFF FFFF FFFF FFFF 

and %xmm1 contains the 128-bit value (written in hexadecimal):

0xFFFF FFFF FFFF FFFE 0000 0000 0000 0003 

Then %xmm0 would be treated as containing the numbers 1 and -1 (or 0xFFFFFFFFFFFFFFFF), and %xmm1 as containing the numbers -2 and 3. paddd would add 1 and -2 to produce -1 and -1 and 3 to produce 2, so the final value of %xmm1` would be:

0xFFFF FFFF FFFF FFFF 0000 0000 0000 0002

If we interpret this value as an array of two 64-bit integers, then that would be -1 and 2.