High-Performance SIMD in C++

High-Performance SIMD in C++

Recently, I have been looking at related content on vectorization, and it seems a bit overwhelming. Taking this opportunity to learn about high-performance SIMD programming.

SIMD stands for Single Instruction, Multiple Data.

In traditional computer architecture, the CPU can only process one data element at a time. However, many tasks involve performing the same operation on a large amount of data, such as addition, multiplication, or logical operations on all elements of an array. SIMD programming provides the CPU with a specialized instruction set that allows it to perform the same operation on multiple data elements simultaneously.

This processing method is particularly suitable for calculations involving vectors, matrices, images, audio, and video data.

Currently, commonly used SIMD instruction sets include SSE, SSE2, AVX128, AVX256, and AVX512.

In this section, we will briefly learn about some operations of AVX512. There are many operations, so we will only introduce a few.

1. Terminology

The first question is that SIMD programming code looks quite different from the usual code, with various underscores and naming conventions that are completely incomprehensible. How do we understand it?

For example:

  • _mm512_set1_ps

Broadcast single-precision (32-bit) floating-point value a to all elements of dst.

  • _mm512_set1_epi32

Broadcast 32-bit integer a to all elements of dst.

Thus, I found the following table:

Abbreviation Full Name C/C++ Equivalent
ps Packed Single-Precision float
ph Packed Half-Precision None*
pd Packed Double-Precision double
pch Packed Half-Precision Complex None*
pi8 Packed 8-Bit Integer int8_t
pi16 Packed 16-Bit Integer int16_t
pi32 Packed 32-Bit Integer int32_t
epi8 Extended Packed 8-Bit Integer int8_t
epi16 Extended Packed 16-Bit Integer int16_t
epi32 Extended Packed 32-Bit Integer int32_t
epi64 Extended Packed 64-Bit Integer int64_t
epi64x Extended Packed 64-Bit Integer int64_t

https://stackoverflow.com/questions/70911872/what-are-the-names-and-meanings-of-the-intrinsic-vector-element-types-like-epi6

For instance:

_mm512_mask_load_ps

_mm512_mask_loadu_ps

Where ‘u’ indicates unordered, meaning that when using _mm512_mask_loadu_ps to load data from memory, no alignment requirements are imposed on the memory address. In contrast, _mm512_mask_load_ps requires a 64-byte alignment.

By comparing and learning this way, one can quickly understand the meaning of each interface.

For relevant APIs, you can check the Intel Intrinsics Guide.

https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

2. Practical Examples

2.1 Finding Minimum Value

For 512 bits, we can store 16 32-bit floats.

static inline rf_512 load(float* ptr) { return _mm512_loadu_ps(ptr); }
float a[width] = {1.0, 2.0,  3.0,  22.0, 5.0,  17.0, 9.0,  8.0,
                  9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0};
float b[width] = {3.3, 6.2,   5.3,   4.4,   5.5,   6.6,   7.7,  8.8,
                  9.9, 10.10, 21.11, 12.12, 13.13, 14.14, 15.0, 16.16};
rf_512 data = minimum(load(a), load(b));

Thus we can quickly obtain:

1 2 3 4.4 5 6.6 7.7 8 9 10 11 12 13 14 15 16

2.2 Quick Data Shuffling

For the input data:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16

We can quickly obtain:

16.16 2 14.14 13.13 5 6 7 8 9 10 11 12 13 14 15 16

The corresponding implementation:

static inline rf_512 permutexvar(ri_512 idx, rf_512 src) {
  return _mm512_permutexvar_ps(idx, src);
}
/*
raw data: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
shuffle data: 16.16 2 14.14 13.13 5 6 7 8 9 10 11 12 13 14 15 16
*/
void print_permutexvar_mask() {
  float a[width] = {1.0, 2.0,  3.0,  4.0,  5.0,  6.0,  7.0,  8.0,
                    9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0};
  float b[width] = {1.1, 2.2,   3.3,   4.4,   5.5,   6.6,   7.7,  8.8,
                    9.9, 10.10, 11.11, 12.12, 13.13, 14.14, 15.0, 16.16};
  mask_type mask = make_bit_mask<1, 0, 1, 1, 0>();
  int idx_array[width] = {15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
  ri_512 idx = _mm512_loadu_si512((__m512i*)idx_array);
  rf_512 result = permutexvar_mask(load(a), mask, idx, load(b));
  float result_arr[width];
  store(result, result_arr);
  std::cout << "raw data: ";
  for (int i = 0; i < width; i++) {
    std::cout << a[i] << " ";
  }
  std::cout << std::endl;

  std::cout << "shuffle data: ";
  for (int i = 0; i < width; i++) {
    std::cout << result_arr[i] << " ";
  }
  std::cout << std::endl;
}

2.3 Rotation

For an array:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16

We can rotate to obtain:

4 5 6 7 8 9 10 11 12 13 14 15 16 1 2 3

or

14 15 16 1 2 3 4 5 6 7 8 9 10 11 12 13

This means rotating down or rotating up.

Here, we can implement it like this:

rf_512 rotateGeneral(float* arr, int s) {
  int idx_array[width];
  for (int i = 0; i < width; i++) {
    idx_array[i] = (i + s) % width;
  }
  ri_512 idx = _mm512_loadu_si512((__m512i*)idx_array);
  rf_512 result = permutexvar(idx, load(arr));
  return result;
}

And there are other examples; we can find that by using SIMD, we can implement some very interesting algorithms to accelerate the processing of arrays and bulk data.

We will continue to learn SIMD together, let’s keep it up!

Note: The complete example of this section has been updated on the platform, thank you!

High-Performance SIMD in C++

Leave a Comment