Unités SIMD

Ressources

Introduction

Les unités SIMD (Single Instruction on Multiple Data) sont présentes dans la plupart des processeurs actuels : x86, PowerPC, ARM, sous différents noms, respectivement SSE, AltiVec, NEON. Contrairement aux unités scalaires ALU (Arithmetic & Logic Unit) et FPU (Floating Point Unit), les unités SIMD peuvent appliquer une même instruction sur plusieurs données simultanément. Par exemple les unités SSE permettent d’additionner 4 flottants 32 bits en une instruction au lieu de 4. Pour parvenir à cela, ces unités sont dotées d’un ensemble de registres capables de contenir plusieurs données.
L’exploitation des unités SIMD se fait par l’utilisation d’instructions spécifiques accessibles au programmeur au travers d’intrinsics. Les compilateurs sont également capables dans une certaine mesure d’analyser un code séquentiel pour le “vectoriser” c’est à dire fusionner des instructions séquentielles en moins d’instructions SIMD. Il est important de noter que chaque type d’unité SIMD, SSE, AltiVec, NEON, possède son propre jeu d’instructions, le code exploitant les intrinsics n’est donc pas portable d’une architecture à l’autre.

SSE

Les unités SSE (Streaming SIMD Extensions) sont présentes dans les processeurs x86 depuis les Pentiums IV. Elles succèdent aux unités MMX (MultiMedia eXtensions?) apparues sur les Pentiums MMX.
Suivant l’architecture x86, 32 ou 64 bits, une unité SSE comporte respectivement 8 ou 16 registres 128 bits en plus des registres traditionnels. Sur une architecture 64 bits, les 16 registres SSE ne sont accessibles que si le système installé est également 64 bits.

Principe

Le principe de base pour utiliser une unité SIMD est le suivant :

  1. charger les données en mémoire vers les registres SSE
  2. appliquer des opérations SIMD entre les registres
  3. sauvegarder le contenu des registres vers la mémoire

Chargement/Déchargement des données

Le chargement des données vers les registres SSE s’effectue par les fonctions _mm_loadprefix, où prefix indique l’organisation et le type des données à transférer.

Le déchargement des données des registres SSE vers la mémoire s’effectue par les fonctions _mm_storeprefix.

Alignement mémoire

Le transfert d’un bloc de 16 octets de données vers les registres SSE s’effectue de deux manières suivant l’alignement du bloc en mémoire. Soit le bloc est aligné sur 16 octets, c’est à dire que son adresse de base est divisible par 16, soit le bloc est non aligné sur 16 octets.
L’alignement indique comment est placé un bloc d’octets en mémoire, par exemple un entier codé sur 32 bits est aligné par défaut sur 4 octets, c’est à dire que cet entier sera placé dans un bloc mémoire dont l’adresse de départ sera un multiple de 4. Dans le cas d’un caractère d’un octet, son alignement est d’un octet.
L’exemple ci-dessous montre comment obtenir l’alignement d’une variable en fonction de son type à partir de la fonction __alignof__ :

#include 
using namespace std;

int main() {
  cout << __alignof__(int) << endl; // affiche 4
  cout << __alignof__(char) << endl; // affiche 1
}

L'alignement des données est lié à la largeur du bus mémoire qui accède à la mémoire par blocs d'octets et non pas par octet individuellement pour des raisons de performance. Avec un bus 32 bits, on peut donc charger les 4 octets d'un entier 32 bits simultanément s'il est aligné sur 4 octets. Si ce n'est pas le cas, il faut faire deux accès consécutifs à la mémoire pour pouvoir récupérer tous les octets. Pour plus d'information : Wikipédia - Data_structure_alignment

Pour optimiser les opérations de transfert entre la mémoire et les registres SSE, il convient d'aligner les données sur 16 octets de manière à minimiser le nombre d'accès au bus pour récupérer l'intégralité des octets.

Organisation et types des données

Les données peuvent être organisées de 2 manières :

  • packed : plusieurs données contigües en mémoire sur lesquelles on opère simultanément
  • single : une seule donnée

Premier exemple

L'exemple ci-dessous présente l'addition de 2 tableaux de 4 flottants en SSE version 1 :

// inclusion des intrinsics SSE
#include <xmmintrin.h>
#include <iostream>

using namespace std;


int main() {

  // déclaration de 3 tableaux de 4 floats alignés sur 16 octets
  float a1[4] __attribute__((aligned(16))) = {1.0f, 2.0f, 3.0f, 4.0f};
  float a2[4] __attribute__((aligned(16))) = {4.0f, 5.0f, 1.0f, 2.0f};
  float a3[4] __attribute__((aligned(16)));

  // registres SSE
  __m128 r1, r2;

  // chargement des tableaux a1 et a2 dans r1 et r2
  r1 = _mm_load_ps(a1);
  r2 = _mm_load_ps(a2);

  // addition des registres (ps = packed single = 4 floats contigüs)
  r1 = _mm_add_ps(r1, r2);

  // sauvegarde du résultat en mémoire dans a3
  _mm_store_ps(a3, r1);

  for(unsigned int i = 0 ; i < 4 ; ++i) {
    cout << a3[i] << endl;
  }

}

La compilation de cet exemple avec GCC se fait à l'aide de la commande suivante :

g++ -o add add.cpp -msse