Saltar a contenido

Última actualización:2026-10-12

Vectorización SIMD en C++

Últimamente estoy viendo contenido relacionado con la vectorización y resulta un poco abrumador; aprovecho la ocasión para aprender programación SIMD de alto rendimiento.

SIMD es el acrónimo de single-instruction multiple-data: una sola instrucción, múltiples datos.

En la arquitectura tradicional de los ordenadores, la CPU solo puede procesar un elemento de datos cada vez. Sin embargo, muchas tareas implican ejecutar la misma operación sobre gran cantidad de datos, como sumar, multiplicar o aplicar operaciones lógicas a todos los elementos de un array. La programación SIMD dota a la CPU de juegos de instrucciones especializados que le permiten ejecutar la misma operación sobre varios elementos de datos a la vez.

Esta forma de procesamiento es especialmente adecuada para cálculos con datos como vectores, matrices, imágenes, audio y vídeo.

Los más usados actualmente son SSE, SSE2, AVX128, AVX256 y AVX512.

En esta sección aprenderemos de forma sencilla algunas operaciones de AVX512; hay muchas, así que aquí solo introducimos algunas.

1. Terminología

El primer problema es que el código de programación simd no se parece mucho al que solemos escribir: tantos guiones bajos y nombres resultan incomprensibles. ¿Cómo entenderlos?

Por ejemplo:

  • _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.

Así que encontré la siguiente tabla:

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

Otro ejemplo:

_mm512_mask_load_ps

_mm512_mask_loadu_ps

La u significa unordered e indica una carga sin orden: al usar la función _mm512_mask_loadu_ps para cargar datos de la memoria no se aplica ningún requisito de alineación de la dirección de memoria, mientras que _mm512_mask_load_ps exige que se cumpla un alineamiento de 64 bytes.

Estudiando con esta tabla a mano, se entiende muy rápido el significado de cada interfaz.

Puedes consultar las API relacionadas en la Intel Intrinsics Guide.

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

2. Ejemplos prácticos

2.1 Obtener el mínimo

Con 512 bits podemos almacenar 16 floats de 32 bits.

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));

Así podemos obtener rápidamente:

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

2.2 Barajar rápidamente el orden de los datos

Si los datos de entrada son

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

podemos obtener rápidamente:

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

Implementación correspondiente:

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 Rotación

Para un array:

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

podemos obtener por rotación:

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

Es decir, rotate down o rotate up.

Aquí podemos implementarlo así:

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;
}

Etcétera; hay más ejemplos. Se puede comprobar que, usando simd, podemos implementar algoritmos muy interesantes y acelerar el procesamiento de arrays y de datos por lotes.

Seguiré aprendiendo simd más adelante; ¡esforcémonos juntos~

Nota: el ejemplo completo de esta sección ya está publicado en la comunidad 星球, gracias~

Temas relacionados

Comentarios