:: Enseignements :: Master :: M1 :: 2024-2025 :: Java Avancé ::
[LOGO]

Single instruction, Multiple Data (SIMD)


Vectorisation, Décomposition de boucles, Opération binaires et Masques
Nous allons utiliser l'API jdk.incubator.vector intégré au JDK 23 (en incubation).

Exercice 1 - Maven

Comme pour les TPs précédents, nous allons utiliser Maven comme outil de build. Dans ce TP, nous avons trois besoins spécifiques
  • Pour les tests, en plus de la bibliothèque JUnit habituelle, nous allons utiliser la bibliothèque junit-jupiter-params qui permet de déclarer des tests paramétrés, des tests qui peuvent prendre plusieurs algorithmes en entrée.
          <dependency>
             <groupId>org.junit.jupiter</groupId>
             <artifactId>junit-jupiter-api</artifactId>
             <version>5.11.0</version>
             <scope>test</scope>
          </dependency>
          <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-params</artifactId>
            <version>5.11.0</version>
            <scope>test</scope>
          </dependency>
         
  • On va utiliser la bibliothèque JMH qui permet de tester la performance de code écrit Java. JMH fonctionne avec un ensemble d'annotation comme @Benchmark et un processeur d'annotation qui transforme le code des benchmarks en tests exécutables isolés du reste du système (donc les résultats seront reproductibles).
         <dependency>
           <groupId>org.openjdk.jmh</groupId>
           <artifactId>jmh-core</artifactId>
           <version>1.37</version>
         </dependency>
         <dependency>
           <groupId>org.openjdk.jmh</groupId>
           <artifactId>jmh-generator-annprocess</artifactId>
           <version>1.37</version>
           <scope>provided</scope>
         </dependency>
        
    Il faut configurer le maven-compiler-plugin pour qu'il utilise le processeur d'annotation de JMH lors de la compilation.
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-compiler-plugin</artifactId>
           <version>3.13.0</version>
           <configuration>
             <release>23</release>
             <annotationProcessorPaths>
               <path>
                 <groupId>org.openjdk.jmh</groupId>
                 <artifactId>jmh-generator-annprocess</artifactId>
                 <version>1.37</version>
               </path>
             </annotationProcessorPaths>
           </configuration>
         </plugin>
        
    Enfin, on utilise le plugin maven-shade-plugin qui prend un code et ses dépendances et créer un seul jar exécutable nommé benchmarks.jar.
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-shade-plugin</artifactId>
           <version>3.6.0</version>
           <executions>
             <execution>
               <phase>package</phase>
               <goals>
                 <goal>shade</goal>
               </goals>
               <configuration>
                 <finalName>benchmarks</finalName>
                 <transformers>
                   <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                     <mainClass>org.openjdk.jmh.Main</mainClass>
                   </transformer>
                 </transformers>
                 <filters>
                   <filter>
                     <artifact>*:*</artifact>
                     <excludes>
                       <exclude>**/module-info.class</exclude>
                       <exclude>META-INF/MANIFEST.MF</exclude>
                     </excludes>
                   </filter>
                 </filters>
               </configuration>
             </execution>
           </executions>
         </plugin>
        
  • De plus on utilise le module jdk.incubator.vector qui est un module en incubation (l'API n'est pas finalisée), il faut l'activer explicitement avec --add-modules à la compilation et à l'exécution des tests.
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-compiler-plugin</artifactId>
           <version>3.13.0</version>
           <configuration>
             <release>23</release>
             <compilerArgs>
               <compilerArg>--add-modules</compilerArg>
               <compilerArg>jdk.incubator.vector</compilerArg>
             </compilerArgs>
           </configuration>
         </plugin>
    
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-surefire-plugin</artifactId>
           <version>3.5.0</version>
           <configuration>
             <argLine>--add-modules jdk.incubator.vector</argLine>
           </configuration>
         </plugin>
        
Comme précédemment, créer un projet Maven en cochant create simple project au niveau du premier écran, puis passer à l'écran suivant en indiquant Next.
Pour ce TP, le groupId est fr.uge.simd , l'artefactId est simd et la version est 0.0.1-SNAPSHOT. Pour finir, cliquer sur Finish.

Exercice 2 - Vectorized Add / Vectorized Min

Le but de cet exercice est de calculer la somme et le minimum d'un tableau de façon vectorisée (en utilisant des vecteurs de valeurs). Nous allons pour cela créer une classe fr.uge.simd.VectorComputation (dans src/main/java) qui contiendra les différentes méthodes statiques.
Les tests unitaires correspondants s'appellent fr.uge.simd.VectorComputationTest (dans src/test/java).
Note : il est possible de lancer les tests directement, mais il faut penser à rajouter --add-modules jdk.incubator.vector dans les options de la VM.
Pour Maven, sous Eclipse, clic droit sur le projet, "run as..." puis maven clean et maven install.
De plus la classe fr.uge.simd.VectorComputationBenchmark (dans src/main/java) contient les tests de performance JMH pour vérifier que le code que vous avez écrit est bel et bien plus performant qu'une simple boucle sur les données du tableau.
Note : par défaut les benchmarks (les méthodes annotées avec @Benchmark) sont commentés, il faut les dé-commenter si vous voulez exécuter un benchmark. Et il faut penser à produire le jar correspondant.

  1. On cherche à écrire une fonction sum qui calcule la somme des entiers d'un tableau passé en paramètre. Pour cela, nous allons utiliser l'API de vectorisation pour calculer la somme sur des vecteurs.
    • Quelle est la classe qui représente des vecteurs d'entiers ?
    • Qu'est ce qu'un VectorSpecies et quelle est la valeur de VectorSpecies que nous allons utiliser dans notre cas ?
    • Comment créer un vecteur contenant des zéros et ayant un nombre préféré de lanes ?
    • Comment calculer la taille de la boucle sur les vecteurs (loopBound) ?
    • Comment faire la somme de deux vecteurs d'entiers ?
    • Comment faire la somme de toutes les lanes d'un vecteur d'entiers ?
    • Si la longueur du tableau n'est pas un multiple du nombre de lanes, on va utiliser une post-loop, quel doit être le code de la post-loop ?

    Une fois que vous avez répondu à toutes ces questions, écrire le code de sum et vérifier que le test nommé "testSum" passe. De plus, vérifier avec les tests de performance dans VectorComputationBenchMark (dé-commenter les annotations correspondantes) que votre code est plus efficace qu'une simple boucle.
    Rappel : pour lancer les tests JMH, il suffit d'exécuter java -jar target/benchmarks.jar dans un terminal (et arrêter tout les programmes qui tournent !).
  2. On souhaite écrire une méthode sumMask qui évite d'utiliser une post-loop et utilise un mask à la place.
    • Comment peut-on faire une addition de deux vecteurs avec un mask ?
    • Comment faire pour créer un mask qui allume les bits entre i la variable de boucle et length la longueur du tableau ?

    Écrire le code de la méthode sumMask et vérifier que le test "testSumMask" passe.
    Que pouvez dire en termes de performance entre sum et sumMask en utilisant les tests de performances JMH ?
  3. On souhaite maintenant écrire une méthode min qui calcule le minimum des valeurs d'un tableau en utilisant des vecteurs et une post-loop.
    Contrairement à la somme qui a 0 comme élément nul, le minimum n'a pas d'élément nul... Quelle doit être la valeur utilisée pour initialiser toutes les lanes du vecteur avant la boucle principale ?
    Écrire le code de la méthode min, vérifier que le test nommé "testMin" passe et vérifier avec les tests JMH que votre code est plus efficace qu'une simple boucle sur les valeurs du tableau.
  4. On souhaite enfin écrire une méthode minMask qui au lieu d'utiliser une post-loop comme dans le code précédent, utilise un mask à la place.
    Attention, le minimum n'a pas d’élément nul (non, toujours pas !), donc on ne peut pas laisser des zéros "traîner" dans les lanes lorsque l'on fait un minimum sur deux vecteurs.
    Écrire le code de la méthode minMask et vérifier que le test nommé "testMinMask" passe.
    Que pouvez-vous dire en termes de performance entre min et minMask en utilisant les tests de performances JMH ?

Exercice 3 - FastScanList

On souhaite améliorer la vitesse moyenne (pas le pire cas, pour cela il ne faut pas prendre une liste) des recherches dans une liste.
L'idée est la suivante : chercher dans une liste demande à appeler la méthode Object.equals() sur chaque élément de la liste. Or, on peut accélérer un peu les choses en comparant les hashCode() AVANT d'appeler equals()... En effet, cela peut permettre de gagner en performances : si, par exemple, les éléments sont des chaînes de caractères, cela revient à comparer un entier au lieu de parcourir la chaîne de caractères et comparer les caractères un à un (dans le cas où les chaines sont différentes).
On se propose de créer une classe FastScanList qui, en plus du tableau des éléments, maintient un tableau des hashCodes. Pour éviter d'utiliser trop d'espace mémoire (et pour pouvoir plus tard utiliser l'API des vectors), nous n'allons pas stocker le hashCode complet sur un int mais seulement une portion sur un byte.
Pour la classe FastScanList, on se propose d'utiliser le code suivant (dans src/main/java)
public class FastScanList<E> {
  private E[] array;
  private byte[] hashes;
  private int size;

  public FastScanList() {
    @SuppressWarnings("unchecked")
    var array = (E[]) new Object[16];
    this.array = array;
    this.hashes = new byte[16];
  }

  public int size() {
    return size;
  }

  private void resize() {
    var newLength = size << 1;
    array = Arrays.copyOf(array, newLength);
    hashes = Arrays.copyOf(hashes, newLength);
  }

  public void add(E element) {
    Objects.requireNonNull(element);
    if (array.length == size) {
      resize();
    }
    array[size] = element;
    hashes[size] = (byte) element.hashCode();
    size++;
  }

  @Override
  public String toString() {
    return IntStream.range(0, size)
        .mapToObj(i -> array[i] + " (" + (hashes[i] & 0xFF) + ")")
        .collect(Collectors.joining(", ", "[", "]"));
  }

  public boolean contains(Object o) {
    Objects.requireNonNull(o);
    var hash = (byte) o.hashCode();
    for(var i = 0; i < size; i++) {
      if (o.equals(array[i])) {
        return true;
      }
    }
    return false;
  }
}
  
Les tests unitaires correspondants s'appellent fr.uge.simd.FastScanListTest (dans src/test/java).
De plus la classe fr.uge.simd.FastScanListBenchmark (dans src/main/java) contient les tests de performance JMH.

  1. Vérifier que vous avez bien mis les fichiers au bon endroit et que les tests Q1 passent.
    Lancer le benchmark de performance pour obtenir les temps si l'on cherche un élément présent ou pas dans la liste.

  2. L'implantation actuelle de contains n'utilise pas le tableau contenant les valeurs de hachage.
    Changer le code pour améliorer les performances.
    Vérifier que les tests Q2 passent.
    Relancer le benchmark de performance sur votre nouvelle implantation pour vérifier qu'elle est bien plus rapide.

  3. En fait, on peut améliorer le code en utilisant l'API de vectorisation, afin d'aller chercher plus rapidement s'il y a la valeur de hachage recherchée. L'idée est de comparer plusieurs bytes en une fois en utilisant la méthode Vector.eq(vector).
    Sachant nous allons utiliser les méthodes VectorMask.anyTrue() et mask.laneIsSet(index), écrire une nouvelle implantation de la méthode contains utilisant l'API vectorisée.
    Vérifier que les tests Q3 passent.
    Lancer le benchmark de performance sur votre nouvelle implantation pour vérifier qu'elle est bien plus rapide que la précédente.