Performance de la POO avec Javascript

Vous vous attendiez à la suite sur la création de notre jeu d'avion en Javascript + Canvas ? Au risque de vous décevoir, il reste un point que nous avons pas vu lors du dernier billet : la performance liée à l'utilisation de la POO. En effet, j'ai annoncé que d'instancier une classe déclarée à la dégueulasse était plus lent et gourmand en mémoire que d'instancier une classe déclarée avec l'objet prototype. Plutôt que de me croire sur parole pourquoi ne pas tester tout ça et transformer ces belles paroles en preuves irréfutables ? C'est ce que je vous propose de suivre durant ce billet.

T'es Pile dans le Tas

Chaque programme qui s'éxecute s'appuie sur trois types d'allocation de la mémoire vive (RAM).

  • l'allocation statique
  • l'allocation dynamique sur la pile
  • l'allocation dynamique sur le tas

l'allocation statique (text) est faite par des constantes au sein du programme. Dans un langage compilé, l'espace à allouer est défini à la compilation en binaire, et dès le début de l'éxecution du programme, l'espace dédié est alloué et est à taille fixe pour toute la durée du programme. En Javascript, nous ne pouvons pas faire d'allocation statique car le langage étant interprété, il n'est pas possible de définir une quantité de mémoire à allouer avant l'éxecution du programme.

l'allocation dynamique sur la pile (stack) est faite au moment où l'on rentre dans une fonction. Le contexte est mémorisé, et toute variable (paramètres de la fonction) est ajouté à la pile. C'est une simple LIFO (Last In First Out), et à la sortie de la fonction, la pile est désallouée du dernier élement entré au premier. Cette allocation se fait de manière automatique et ce n'est pas le programmeur qui décide de cette allocation mais la structure de son programme.

l'allocation dynamique sur le tas (heap) est faite dès qu'un besoin en mémoire durant le programme est nécessaire et qu'il est réalisé par le programme. C'est le cas lorsqu'on déclare une nouvelle variable, ou une nouvelle instance.

Pour la suite du billet, la seule chose qui va nous intéresser ici sera l'allocation dynamique sur le tas, seule partie que nous pouvons maitriser/optimiser dans notre script Javascript.

Le script de test

Il contient le nécessaire pour réaliser des mesures de temps d'éxecution.

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Performance de la POO avec Javascript</title>
    <link rel="stylesheet" type="text/css" href="../common/css/demo.css">

    <script type="text/javascript">

            // Insérer votre code ici

    </script>
</head>
<body><h1>Performance de la POO avec Javascript</h1>
<p><strong>NOTE</strong> : ce test ne fonctionnera que sur Firefox et Chrome (toute version)</p>
<p><!-- Bouton d'action -->
    Créer
    <input type="text" id="iteration" value="2000000" /> instances de
    <input type="button" onclick="testObjetsTypeA();" value="ClassA (constructeur)" />
    <input type="button" onclick="testObjetsTypeB();" value="ClassB (prototype)" /> ou
    <input type="button" onclick="resetTest();" value="Effacer les références aux objets" />
</p>
<p><!-- Champs de debug -->
    Total Time :
    <input type="text" id="debug_gap" value="0" /> ms<br/>
    Pour vérifier la consommation mémoire, tapez <strong>about:memory</strong> dans la barre d'adresse de votre navigateur.
</p>
</body></html>

Et nous mettrons en place le Javascript. Tout d'abord, mettons en place les définitions de nos deux classes à tester : ClasseA et ClasseB.

/**
 * Version avec constructeur
 */
var ClasseA = function(a,b) {
    this.a = a;
    this.b = b;

    this.somme = function() {
        return this.a + this.b;
    }

    this.diff = function() {
        return this.a + this.b;
    }

    this.multiplie = function() {
        return this.a * this.b;
    }

    this.divide = function() {
        return this.a / this.b;
    }

    this.test = function() {
        if (this.a < this.b) {
            return this.b;
        }
        else {
            return this.a
        }
    }
}

/**
 * Version avec Prototype
 */
var ClasseB = function(a,b) {
    this.a = a;
    this.b = b;
}
ClasseB.prototype.somme = function(){
    return this.a + this.b;
}

ClasseB.prototype.diff = function(){
    return this.a + this.b;
}

ClasseB.prototype.multiplie = function(){
    return this.a * this.b;
}

ClasseB.prototype.divide = function(){
    return this.a / this.b;
}

ClasseB.prototype.test = function() {
    if (this.a < this.b) {
        return this.b;
    }
    else {
        return this.a;
    }
}

Ces deux classes font strictement la même chose. Seules les façons de les définir diffèrent.

Nous allons maintenant ajouter le nécessaire pour les tester.

/**
 * Méthode pour déterminer le temps passé entre deux appels
 */
var exec_time = 0; /* Temps d'execution */
var timeref = (new Date()).getTime();
var debug_gap = function(){
    var new_time = (new Date()).getTime();
    var diff = new_time - timeref;
    timeref = new_time;
    return diff;
}

/**
 * Création de 'nombreIter' objets de type ClasseA
 */
var testObjetsTypeA = function() {
    var nombreIter = document.getElementById('iteration').value;
    debug_gap(); /* démarrage du compteur */
    listObject = [];
    for (var i=0; i<nombreIter; i++) {
        var myclass = new ClasseA(3,2);
        myclass.somme();
        myclass.diff();
        myclass.multiplie();
        myclass.divide();
        myclass.test();
        listObject.push(myclass);
    }
    exec_time = debug_gap(); /* arrêt du compteur */

    document.getElementById('iteration').value = nombreIter;
    document.getElementById('debug_gap').value = exec_time;
}

/**
 * Création de 'nombreIter' objets de type ClasseB
 */
var testObjetsTypeB = function() {
    var nombreIter = document.getElementById('iteration').value;
    debug_gap(); /* démarrage du compteur */
    listObject = [];
    for (var i=0; i<nombreIter; i++) {
        var myclass = new ClasseB(3,2);
        myclass.somme();
        myclass.diff();
        myclass.multiplie();
        myclass.divide();
        myclass.test();
        listObject.push(myclass);
    }
    exec_time = debug_gap(); /* arrêt du compteur */

    document.getElementById('iteration').value = nombreIter;
    document.getElementById('debug_gap').value = exec_time;
}

/**
 * On efface les objets en mémoire
 */
 var resetTest = function() {
    listObject = [];
    document.getElementById('debug_gap').value = 0;
 }

Protocole de test

Notre test ne s'effectuera que sous Mozilla Firefox et Google Chrome qui disposent d'outils pour mesurer la mémoire allouée par notre script de test.

Ce test est disponible ici.

  1. Lancer un Firefox ou un Chrome tout frais
  2. Aller à l'URL de notre page de test.
  3. Définir le nombre d'objet à créer
  4. Cliquer sur le type de classe à tester
  5. Noter le temps d'execution et vérifier la consommation mémoire : sous Chrome ou sous Firefox

Dans tous nos tests nous partirons sur 2000000 objets à créer.

Machine et navigateurs de test

La machine de test est un PC sous Ubuntu 11.10 64 bits sous Gnome Shell avec accélération graphique opérationnelle (AMD) et processeur Core 2 Duo E8400 de 3 GHz et 4 Go de RAM DDR2.

La version de Mozilla Firefox est la 8.0 (paquet officiel).

La version de Google Chrome est la 16.0.912.63 (paquet deb64 du site source).

Tests avec la déclaration par constructeur

Nous allons instancier 2000000 objets de type ClasseA

Résultats sous Mozilla Firefox

Temps d'éxecution : 827 ms

Empreinte mémoire :

355,701,235 B (53.19%) -- compartment(http://kyoku57.org/demo/animations-canvas/test_00/)
│  │  ├──210,268,160 B (31.44%) -- gc-heap
│  │  │  ├──208,080,712 B (31.12%) -- objects
│  │  │  ├────1,642,720 B (00.25%) -- arena-headers
│  │  │  ├──────413,216 B (00.06%) -- arena-padding
│  │  │  ├───────87,872 B (00.01%) -- shapes
│  │  │  └───────43,640 B (00.01%) -- arena-unused
│  │  ├──144,789,088 B (21.65%) -- object-slots
│  │  ├──────404,552 B (00.06%) -- tjit-data
│  │  │      ├──172,224 B (00.03%) -- trace-monitor
│  │  │      ├──148,000 B (00.02%) -- allocators-reserve
│  │  │      └───84,328 B (00.01%) -- allocators-main
│  │  ├──────131,072 B (00.02%) -- tjit-code
│  │  ├───────65,536 B (00.01%) -- mjit-code
│  │  ├───────24,176 B (00.00%) -- property-tables
│  │  ├───────12,772 B (00.00%) -- mjit-data
│  │  └────────5,879 B (00.00%) -- scripts

Résultats sous Google Chrome

Temps d'éxecution : 5144 ms

Empreinte mémoire :

Tab
Performance de la POO avec Javascript
928,648k

Tests avec la déclaration par prototype

Nous allons instancier 2000000 objets de type ClasseB

Résultats sous Mozilla Firefox

Temps d'éxecution : 280 ms

Empreinte mémoire :

│  ├──227,700,015 B (40.12%) -- compartment(http://kyoku57.org/demo/animations-canvas/test_00/)
│  │  ├──210,272,256 B (37.05%) -- gc-heap
│  │  │  ├──208,080,712 B (36.66%) -- objects
│  │  │  ├────1,642,752 B (00.29%) -- arena-headers
│  │  │  ├──────413,224 B (00.07%) -- arena-padding
│  │  │  ├───────87,552 B (00.02%) -- shapes
│  │  │  └───────48,016 B (00.01%) -- arena-unused
│  │  ├───16,789,088 B (02.96%) -- object-slots
│  │  ├──────412,616 B (00.07%) -- tjit-data
│  │  │      ├──172,224 B (00.03%) -- trace-monitor
│  │  │      ├──148,000 B (00.03%) -- allocators-reserve
│  │  │      └───92,392 B (00.02%) -- allocators-main
│  │  ├──────131,072 B (00.02%) -- tjit-code
│  │  ├───────65,536 B (00.01%) -- mjit-code
│  │  ├───────23,568 B (00.00%) -- property-tables
│  │  └────────5,879 B (00.00%) -- scripts

Résultats sous Google Chrome

Temps d'éxecution : 668 ms

Empreinte mémoire :

Tab
Performance de la POO avec Javascript
126,452k

Interprétation des résultats

Nous n'allons pas comparer les navigateurs. Ce n'est pas l'objectif et ça n'aurait pas de sens pour ce test vu qu'on cherche à mesurer les gains entre deux façons de faire dans un même navigateur.

Vitesse d'éxecution

Première chose évidente, l'instanciation de nos objets avec notre classe définie à l'aide d'un prototype est plus rapide à l'éxecution que la même opération via notre classe définie par son unique constructeur.

Pour Firefox, nous passons de 827ms à 280ms, soit un gain en vitesse de 68%.

Pour Chrome, nous passons de 5144ms à 668ms soit un gain en vitesse de 87%.

Occupation mémoire

Là aussi, pas de surprises réelles, l'instanciation de nos objets avec notre classe définie à l'aide d'un prototype est moins gourmande en mémoire que la même opération via notre classe définie par son unique constructeur.

Pour Firefox, nous passons de 355701235o à 227700015o soit un gain en mémoire de 40%.

Pour Chrome, nous passons de 928648ko à 126452ko soit un gain en mémoire de 86%.

Conclusion

Ben oui, finalement je ne racontais pas de conneries lors du précédent billet : il faut utiliser prototype pour déclarer nos classes. Les gains en temps de calculs et en place mémoire sont énormes et nous aurions tort de nous priver d'une optimisation aussi "facile". Et encore quand je dis "optimiser", je sous entends "ne pas programmer comme un goret sans prendre en compte les spécificités du langage". Il est facile de critiquer Javascript, mais encore faut il le comprendre et malgré toutes les critiques que ce langage peut essuyer, force est d'admettre qu'il revient en force depuis quelques temps et permet de réaliser de merveilleuses choses.

La grande question maintenant est de savoir comment font les frameworks/librairies Javascript courants qui permettent de définir nos classes plus simplement. Comment est converti un "patron" de classe fait avec ExtJS, Dojo ToolKit ou encore Classy?

Je connais la réponse : elles utilisent le pattern prototype (il suffit de regarder le code source de Classy pour s'en convaincre). Mais nous en reparlons plus tard car j'ai assez dévié de l'objectif de départ. Maintenant pour le prochain article nous reviendrons à la manipulation de notre petit avion dans un canvas.

Commentaires