Introduction à Quickperf

Introduction à Quickperf

QuickPerf est une bibliothèque de test pour Java permettant d’évaluer et d’améliorer rapidement les performances de votre application.

Le grand intérêt de Quickperf réside dans le fait que cela s’effecue via des tests unitaires, ce qui permet, après avoir détecté et corrigé un problème de performance, d’avoir des tests de non-régression pour que celui-ci ne revienne jamais ! Vous pouvez alors évaluer en continue les performances de votre application via votre environnement d’intégration continue !

Quickperf peut être utilisé pour simplement mesurer des caractéristiques de performance, ou pour asserter sur celle-ci via la définition d’expectation.

Quickperf nécessite Java 7 minimum et fonctionne avec JUnit 4 ou 5 et TestNG. Les exemples de cet article sont réalisés en Java 11 via JUnit 5.

Pour utiliser Quickperf vous devez ajouter la librairie correspondante à votre framework de test, voici un exemple avec Maven pour JUnit 5.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupid>org.junit</groupid>
            <artifactid>junit-bom</artifactid>
            <version>5.7.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupid>org.quickperf</groupid>
            <artifactid>quick-perf-bom</artifactid>
            <version>1.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
 </dependencyManagement>

  <dependencies>
    <dependency>
      <groupid>org.junit.jupiter</groupid>
      <artifactid>junit-jupiter-engine</artifactid>
      <scope>test</scope>
    </dependency>
    <dependency>
       <groupid>org.junit.platform</groupid>
       <artifactid>junit-platform-launcher</artifactid>
       <scope>test</scope>
    </dependency>
  </dependencies>

Un premier exemple

Avant d’entrer en détail dans les fonctionnalités de Quickperf, un petit exemple :

@QuickPerfTest
public class JvmAnnotationsJunit5Test {

    @MeasureHeapAllocation
    @Test
    public void test_method_measuring_heap_allocation() {
        // java.util.ArrayList: 24 bytes + Object[]: 16 + 100 x 4 = 416 => 440 bytes
        ArrayList data = new ArrayList<>(100);
    }

    @ExpectNoHeapAllocation
    @Test
    public void should_not_allocate() {
    }
}
  • @QuickPerfTest: est utilisé pour initialiser l’extension Quickperf JUnit5 (optionnel)
  • @MeasureHeapAllocation: va mesurer les allocations mémoire faites par le test
  • @ExpectNoHeapAllocation: va créer une expectation comme quoi le test ne doit pas réaliser d’allocation mémoire. Si celle-ci est fausse, le test sera en échec (assertion failure)

Lancer ces tests vous donnera le résultat suivant :

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running org.quickperf.jvm.JvmAnnotationsJunit5Test
[QUICK PERF] Measured heap allocation (test method thread): 440 bytes
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.203 s - in org.quickperf.jvm.JvmAnnotationsJunit5Test
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Build Success! Super!
Au passage, vous remarquerez la mesure qu’a prise Quickperf de l’allocation mémoire du test test_method_measuring_heap_allocation:

[QUICK PERF] Measured heap allocation (test method thread): 440 bytes

Modifions maintenant le code pour ajouter une expectation pour le test test_method_measuring_heap_allocation comme quoi il doit allouer moins de 400 octets de mémoire.

@MeasureHeapAllocation
@ExpectMaxHeapAllocation(value = 400, unit = AllocationUnit.BYTE)
@Test
public void test_method_measuring_heap_allocation() {
    // java.util.ArrayList: 24 bytes + Object[]: 16 + 100 x 4 = 416 => 440 bytes
    ArrayList data = new ArrayList<>(100);
}

Lancer ces tests modifiés vous donnera :

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running org.quickperf.jvm.JvmAnnotationsJunit5Test
[QUICK PERF] Measured heap allocation (test method thread): 440 bytes
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.258 s <<< FAILURE! - in org.quickperf.jvm.JvmAnnotationsJunit5Test
[ERROR] test_method_measuring_heap_allocation  Time elapsed: 1.106 s  <<< FAILURE!
java.lang.AssertionError: 
a performance property is not respected

[PERF] Expected heap allocation (test method thread) to be less than 400 bytes but is 440 bytes.


[INFO] 
[INFO] Results:
[INFO] 
[ERROR] Failures: 
[ERROR]   JvmAnnotationsJunit5Test.test_method_measuring_heap_allocation a performance property is not respected

[PERF] Expected heap allocation (test method thread) to be less than 400 bytes but is 440 bytes.

[INFO] 
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

Build Failure 🙁

Mais avec une explication en toute lettre de la cause.

[PERF] Expected heap allocation (test method thread) to be less than 400 bytes but is 440 bytes.

Une des spécificités de Quickperf est de vous donner le plus d’information possible sur la raison de l’échec du test, via des phrases compréhensibles par un humain.

Les annotations cœurs (core)

Les annotations cœurs sont disponibles au sein de la librairie principale de Quickperf, elles offrent les fonctionnalités suivantes :

  • @MeasureExecutionTime : mesure le temps d’exécution de la méthode de test.
  • @ExpectMaxExecutionTime : créer une expectation comme quoi le test ne doit s’exécuter en plus d’un certain temps.

Il existe aussi un certain nombre d’annotations permettant de configurer Quickperf, plus d’information ici.

Les annotations JVM

La plupart des annotations JVM sont inclues dans la librairie principale de Quickperf sauf les annotations liées à JFR – JDK Flight Recorder qui nécessitent l’ajout de la librairie org.quickperf:quick-perf-jfr-annotations.

Attention : l’utilisation d’annotation JVM induit le démarrage d’une JVM par méthode de test.

Les annotations JVM fournissent les fonctionnalités suivantes :

  • Configuration de la JVM qui exécutera le test via @HeapSize (pour configurer le Xmx et le Xms), @Xms, @Xmx, @UseGC, @EnableGcLogging et @JvmOptions
  • Mesure des allocations mémoire via @MeasureHeapAllocation, @ExpectMaxHeapAllocation et @ExpectNoHeapAllocation. Utilise la librairie ByteWatcher.
  • Mesure du resident set size du process de votre JVM via @MeasureRSS et @ExpectMaxRSS.
  • Profilage de votre application via JFR.

Petit zoom sur le profilage de votre application via JFR. L’annotation @ProfileJvm va lancer un enregistrement JFR pour l’exécution de votre méthode que vous pourrez ensuite ouvrir avec JMC – JDK Mission Control. Cette annotation va aussi afficher dans la console certaines métriques globales de JFR.

------------------------------------------------------------------------------
 ALLOCATION (estimations)     |   GARBAGE COLLECTION           |  THROWABLE
 Total       : 20,1 MiB       |   Total pause     : 21,434 ms  |  Exception: 0
 Inside TLAB : 16,2 MiB       |   Longest GC pause: 21,434 ms  |  Error    : 0
 Outside TLAB: 3,95 MiB       |   Young: 1                     |  Throwable: 0
 Allocation rate: 255 MiB/s   |   Old  : 0                     |
------------------------------------------------------------------------------
 COMPILATION                  |   CODE CACHE
 Number : 0                   |   The number of full code cache events: 0
 Longest: 0                   |   
------------------------------------------------------------------------------

Il est possible de définir des expectations quant à ce profil via l’annotation @ExpectNoJvmIssue qui va se baser sur les règles JMC pour mettre en échec le test.

Rule: Primitive To Object Conversion
Severity: INFO
Score: 73
Message: 78 % of the total allocation (15,1 MiB) is caused by conversion from primitive 
types to object types.

The most common object type that primitives are converted into is 
'java.lang.Integer', which causes 15,1 MiB to be allocated. The most common 
call site is 'org.quickperf.jvm.IntegerAccumulator.accumulateInteger(int):26'.


[...]

Plus d’information sur les annotations JVM ici

Les annotations SQL

Pour utiliser les annotations SQL, il faut ajouter la librairie org.quickperf:quick-perf-sql-annotations (sauf si vous utilisez Spring qui a un support dédié). En fonction de votre application, il faut ensuite configurer sa DataSource pour ajouter un proxy qui permettra à Quickperf de tracer les requêtes SQL. Plus d’informations à ce sujet dans la section suivante.

Quickperf a énormément d’annotations permettant de vérifier l’exécution des requêtes SQL, et de détecter les problèmes les plus classiques : requêtes dupliquées, n+1 select, batch, …

Voici quelques unes des annotations les plus utilisées :

  • @ExpectJdbcQueryExecution et @ExpectMaxQueryExecutionTime : expectation quant à la durée d’exécution d’une requête SQL, la première attend une durée précise, la deuxième sera en échec si la durée spécifiée est atteinte (la plupart des annotations SQL viennent par couple selon ce même schéma).
  • @ExpectSelectedColumn et @ExpectMaxSelectedColumn : expectation quant au nombre de colonnes sélectionnées
  • @ExpectSelect et @ExpectMaxSelect : expectation quant au nombre de select générés
  • @ExpectJdbcBatching : expectation quant à l’utilisation de batch JDBC

Petit zoom sur une annotation un peu particulière : @DisableSameSelectTypesWithDifferentParamValues, celle-ci permet de détecter les problèmes de n+1 select/strong> avec JPA / Hibernate.

Ce problème bien connu arrive quand deux entités ont une relation parent fils, et que le chargement de l’entité parente implique une requête select pour la charger plus n select pour charger ses enfants. C’est un anti-pattern bien connu qui se résout généralement en réalisant un JOIN FETCH JPA.

Quickperf peut détecter cela grâce à l’annotation @DisableSameSelectTypesWithDifferentParamValues qui va mettre votre test en échec si plusieurs select identiques sont exécutés avec des paramètres différents. Et s’il détecte la présence d’Hibernate, Quickperf va même vous donner des conseils quant à la résolution du problème !

Imaginons les entités Player et Team suivantes :

@Entity
public class Player implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private String firstName;

    private String lastName;

    @ManyToOne(targetEntity = Team.class)
    @JoinColumn(name = "team_id")
    private Team team;

    // getter and setters ...
}

@Entity
public class Team implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private String name;

   // getter and setters ...
}

Et le test suivant :

    @DisableSameSelectTypesWithDifferentParamValues
    @Test
    public void should_find_all_players() {

        TypedQuery fromPlayer
                = entityManager.createQuery("FROM Player", Player.class);

        List players = fromPlayer.getResultList();

        assertThat(players).hasSize(2);

    }

Le chargement de la classe Player va entraîner n+1 requêtes SQL qui seront détectées par Quickperf qui vous donnera alors le message d’erreur suivant en suggérant un problème de n+1 et des solutions possibles :

ERROR] Failures: 
[ERROR]   HibernateJUnit5Test.should_find_all_players a performance property is not respected

[PERF] Same SELECT types with different parameter values

💣 You may have even more select statements with production data.
Be careful with the cost of JDBC server roundtrips: https://blog.jooq.org/2017/12/18/the-cost-of-jdbc-server-roundtrips/

💡 Perhaps you are facing an N+1 select issue
    * With Hibernate, you may fix it by using JOIN FETCH
                                           or LEFT JOIN FETCH
                                           or FetchType.LAZY
                                           or ...
      Some examples: https://stackoverflow.com/questions/32453989/what-is-the-solution-for-the-n1-issue-in-jpa-and-hibernate";
                     https://stackoverflow.com/questions/52850442/how-to-get-rid-of-n1-with-jpa-criteria-api-in-hibernate/52945771?stw=2#52945771
                     https://thoughts-on-java.org/jpa-21-entity-graph-part-1-named-entity/

[...]

Intégration dans les frameworks

Comme évoqué dans la section précédente, Quickperf nécessite un proxy de votre DataSource pour pouvoir détecter les problèmes SQL. En fonction des frameworks que vous utilisez, il vous faudra donc mettre en place ce proxy.

Ceci consiste en quelques lignes de code qui vont définir un wrapper au dessus de votre DataSource:

public DataSource wrapDatasource(DataSource dataSource){
    ProxyDataSource proxyDataSource = QuickPerfSqlDataSourceBuilder.aDataSourceBuilder()
            .buildProxy(dataSource);
    return proxyDataSource;
}

Le repository quickperf-examples contient de nombreux exemples de mise en place de ce proxy pour les frameworks les plus courants (Spring, Hibernate, Micronaut, …).

Si vous utilisez Spring, la configuration est simplifiée, il n’est pas nécessaire d’ajouter la librairie org.quickperf:quick-perf-sql-annotations, à la place, vous devez ajouter une librairie dédiée et tout se fait automatiquement. Par exemple, pour Spring Boot 2, vous devez ajouter le starter quick-perf-springboot2-sql-starter.

<dependency>
    <groupid>org.quickperf</groupid>
    <artifactid>quick-perf-springboot2-sql-starter</artifactid>
    <scope>test</scope>
</dependency>

Plus d’information sur le support des annotations SQL avec Spring ici.

Quickperf ne se contente pas de détecter les problèmes de performance, il va aussi donner des conseils sur les manières de les résoudre en listant les causes les plus courantes. Pour ceci il va détecter les frameworks utilisés par votre application (Hibernate, Spring Boot, …) et personnaliser les conseils donnés en conséquence.

En fonction des frameworks détectés, Quickperf va aussi suggérer des solutions aux problèmes d’utilisation de Quickperf lui-même.

Par exemple, en cas de problème de n+1 avec une application qui utilise Spring Data JPA, Quickperf va vous donner une aide à comment le régler spécifiquement avec Spring Data JPA (les conseils pour Hibernate ont été retirés de l’exemple pour améliorer sa lisibilité).

[PERF] You may think that <1> select statement was sent to the database
       But there are in fact <3>...

💣 You may have even more select statements with production data.
Be careful with the cost of JDBC roundtrips: https://blog.jooq.org/2017/12/18/the-cost-of-jdbc-server-roundtrips/

💡 Perhaps you are facing an N+1 select issue
    * With Hibernate [...]

    * With Spring Data JPA, you may fix it by adding @EntityGraph(attributePaths = { "..." })
      on repository method: https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.entity-graph

Conclusion

Quickperf est un outil qui vous permet d’ajouter de la non-régression continue des performances grâce à son intégration via les frameworks de test (JUnit, TestNG).

Il permet de détecter les problèmes de performance les plus courants en Java (allocation mémoire, requêtage, …), de profiler votre application via un test, et plus encore. Une fois ces problèmes détectés et corrigés grâce aux conseils qu’il prodigue, votre test deviendra le garant de la non-régression des performances de votre application.

Quickperf a été utilisé avec succès pour mesuré la consommation de mémoire de Maven entre les versions 3.6.1 et 3.6.2. Suite à ça un bench a été créé qui permet de contrôler en continue les allocations mémoire de Maven. C’est un bel exemple d’utilisation de Quickperf sur un projet complexe et du gain qu’il a apporté.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.