Retour d'expérience sur sql.js

Posté par  (site web personnel) . Édité par Nÿco, ZeroHeure, palm123, Benoît Sibaud et Nils Ratusznik. Modéré par bubar🦥. Licence CC By‑SA.
Étiquettes :
33
15
juin
2014
JavaScript

J'aimerais parler ici de mon expérience lors du développement de sql.js, un port de SQLite en JavaScript. Pour ceux qui ne s’intéressent pas aux technologies du web, la deuxième partie de cette dépêche pourrait quand même vous intéresser, on va parler de SQLite.

Note : cette dépêche a initialement été postée en tant que journal.

Sommaire

Web moderne

Ceux d'entre vous qui s'intéressent aux technologies modernes du web ont certainement entendu parler d’emscripten et d’asm.js.

Emscripten est un compilateur de bytecode LLVM en JavaScript. Il permet de compiler du code C ou C++ en JavaScript, très simplement. Le code JavaScript généré n’est bien sûr pas aussi rapide que le code natif équivalent, mais les performances sont assez bonnes. Sur Firefox, avec asm.js, le code tourne à peu près deux fois plus lentement que la version native. Le principal inconvénient que je trouve à emscripten est qu'il faut, pour l'utiliser sous Linux, télécharger et compiler sa propre version de LLVM et de clang, ce qui est long et pas pratique à mettre à jour.

asm.js, quant à lui, est un sous-ensemble de JavaScript conçu pour être facile à optimiser, et ainsi pouvoir s'exécuter rapidement. La syntaxe est dégueulasse, mais c'est pas grave, emscripten génère du code asm.js pour nous.

SQLite

Vous connaissez certainement déjà SQLite3, le moteur de bases de données le plus utilisé au monde. Si vous vous y êtes déjà un peu intéressé, vous connaissez sûrement ses caractéristiques, qui le rendent unique :

  • Une base de données est stockée dans un seul fichier.
  • Le binaire SQLite est minuscule (moins d'un Mo), et la bibliothèque SQLite peut être liée statiquement dans votre programme. Tout le code source tient dans un fichier sqlite3.c de 5 Mo.
  • Le code a été placé dans le domaine public.
  • Les données sont typées dynamiquement. Une même colonne peut contenir un entier, un nombre à virgule flottante, et une chaîne de caractères, par exemple.

Par contre, vous ne connaissez peut-être pas SQLite4, une évolution de SQLite3 (par les mêmes développeurs), qui n’a pas encore de version stable (et que j’ai aussi porté en JavaScript). Cette version apporte de bien meilleures performances, et surtout, elle utilise un système de base de données de type clef-valeur, que l'on peut changer à la volée.

Et ça, c’est génial ! Cela signifie que l'on pourra bientôt profiter de tous les avantages de SQLite même pour de grosses bases de données. Il suffira d’utiliser un système de base de données clef-valeur qui supporte les grands ensembles de données, comme LevelDB.

Quand on mélange les deux…

sql.js, avant

sql.js, un port de SQLite en JavaScript, était au départ un projet de kripken, le créateur et principal mainteneur d’emscripten, qui date de début 2012. Le port fonctionnait, mais question fonctionnalités, on restait un peu sur sa faim : une seule méthode db.exec, qui exécutait du SQL et retournait les résultats dans un format pas pratique. Les données étaient toujours converties en chaînes de caractères avant d’être retournées. Le projet n’avait aucun test unitaire, le dernier commit date d’il y a plus d’un an, et l’auteur ne répond plus sur le bugtracker… (NdM.: voir le commentaire indiquant que l'auteur a depuis incorporé la contribution)

Pourtant, le projet semble avoir des utilisateurs. 104 forks et 883 stars sur github, et plusieurs téléchargements par jour sur npm à l’heure où j’écris ces lignes.

sql.js, maintenant

Je suis étudiant, et lors d’un TD, j’ai eu besoin de pouvoir tester des commandes en SQL, sur un ordi avec rien du tout d’installé. Je ne connaissais pas encore SQLfiddle, mais j’avais déjà entendu parler de sql.js, donc j’ai utilisé sa démonstration en ligne.

Le soir, en rentrant chez moi, très agaçé des petits défauts de la démonstration que j'avais utilisée, j'ai forké le projet, et commencé à travailler sur une meilleure interface graphique. Quand j'ai été content de moi, j’ai fait une pull request. Comme l’auteur tardait à répondre, j’ai commencé à bidouiller le reste du code. Et de fil en aiguille, j'ai fini par réécrire tout le code, à changer l’API pour avoir quelque chose de facile à utiliser, à ajouter des tests, de la documentation… Et je suis assez fier de l’état du projet aujourd’hui.

Il est utilisable sans modification à la fois depuis node.js, dans le navigateur, et en tant que web worker. Il est disponible sur npm, et s’utilise avec un simple :

var sql = require('sql.js');
var db = new sql.Database();

Il retourne les données dans leur format original, y-compris les BLOBs, retournés sous forme de tableau d’octets :

var res = db.exec("SELECT * FROM table1; SELECT * FROM table2");
// Ce qui peut retourner:
[
    {columns:['a','b'], values:[[0,'hello'],[1,'world']]}, //Le contenu de table1
    {columns:['c','d'], values:[[null,[1,2,3]],[42,'blabla'],[666,666]]} //Celui de table2
    // Et oui, la colonne d contient des données de types différents. C’est possible grâce à SQLite. C’est impossible avec presque tous les autres SGBD
]

Et il permet d'utiliser des requêtes préparées (prepared statements), auxquelles on associe les paramètres que l'on veut, sans risquer de vilaines injections SQL :

db.run("INSERT INTO table1 VALUES (?,?)", [3,[121,111,117,99,114,97,99,107,101,100,105,116]]);

Mais aussi :

var stmt = db.prepare("SELECT * FROM table1 WHERE a=$aval AND b=$bval");
var result = stmt.getAsObject({$aval : 1, $bval : 'world'});
// result contient maintenant:
{a:1, b:'world'}

Comment ça marche

SQLite est distribué sous différentes formes, dont une qui est particulièrement pratique : l’amalgamation. C’est un unique fichier .c de 5Mo qui contient tout le code, et que l’on peut compiler sans aucune bibliothèque externe:

gcc -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_THREADSAFE=0 -c sqlite3.c

Et on peut aussi le compiler, sans aucune modification, avec emscripten. Il suffit de remplacer gcc par emcc dans la commande précédente, et le tour est joué.

Vous allez peut-être trouver bizarre que ça juste marche. En effet, le code de SQLite fait plein de trucs que l’on ne peut pas faire de base en JavaScript dans un navigateur, comme par exemple ouvrir des fichiers, ou simplement accéder à la mémoire à partir de pointeurs.

Heureusement, emscripten s’occupe de tout ça pour nous. Il fabrique un grand tableau d’entiers en JavaScript qui contiendra toute la mémoire de notre programme. Les performances ne sont pas trop mauvaises grâce aux [typed arrays (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays). Les pointeurs ne sont dès lors plus que des index dans ce tableau. Il implémente également les fonctions fopen, fwrite, etc. en émulant un système de fichiers entièrement en mémoire. L’inconvénient étant bien sûr que l’on a beaucoup moins de place pour stocker ses données.

Mon travail consiste alors uniquement à définir les bonnes options de compilation, et à écrire le code qui va faire l’interface entre les programmes en JavaScript et le code compilé, en JavaScript lui aussi. En effet, appeler directement le code résultant de la compilation par emscripten serait terriblement rebutant : ça parle de pointeurs partout, il faut allouer et désallouer de la mémoire dès que l’on veut faire des trucs compliqués, on n’a accès qu’à une série de fonctions, et pas à des objets qui contiennent des méthodes…

Pour avoir un code un peu plus léger, j’ai choisi le CoffeeScript, langage par excellence du web moderne.

Pour vous donner un exemple du genre de code qui fait l’interface, voilà la fonction prepare, qui permet de créer une requête préparée :

    'prepare': (sql, params) ->
        setValue apiTemp, 0, 'i32'
        @handleError sqlite3_prepare_v2 @db, sql, -1, apiTemp, NULL
        pStmt = getValue apiTemp, 'i32' #  pointer to a statement, or null
        if pStmt is NULL then throw 'Nothing to prepare'
        stmt = new Statement pStmt, this
        if params? then stmt.bind params
        @statements.push stmt
        return stmt

On voit bien que tout le vrai travail est fait par les fonctions compilées de SQLite, mon code se contentant de la gestion des erreurs, de la mémoire, et de la conversion des données dans un format utile au programmeur JavaScript.

Conclusion

Aujourd’hui, on fait tout ce qu’on veut sur le web, et bientôt, ce sera encore mieux. Les trucs funs qui sont pour bientôt : ECMASCRIPT 6, aka Harmony, emscripten SDK pour Linux, sqlite4.js

Aller plus loin

  • # Note

    Posté par  (site web personnel) . Évalué à 9.

    Kripken, l’auteur original du projet, m’a finalement ajouté aux contributeurs de sql.js sur son dépôt github, entre le moment ou j’ai écrit cette dépêche et le moment où elle a été publiée. Donc kripken/sql.js contient maintenant le même code que lovasoa/sql.js.

  • # Mais donc les performances ?

    Posté par  . Évalué à 4.

    On s'attendrait à des comparaisons de performances, soit dans la dépêche soit sur la page github du projet. C'est quoi le facteur de ralentissement par rapport à utiliser SQLite directement ?

    (Je trouve le terme de "port en Javascript" assez discutable pour ce projet, qui compile le code de SQLite en Javascript. Pour moi, un port ce serait une récriture du code de SQLite en Javascript, ce qui n'a pas été fait ici—un projet aussi intéressant mais certainement très difficile. Pourquoi ne pas simplement dire "compilation Javascript de SQLite" ?)

    • [^] # Re: Mais donc les performances ?

      Posté par  . Évalué à 3.

      C'est quoi le facteur de ralentissement par rapport à utiliser SQLite directement ?

      Comment tu utilise SQLite directement en JavaScript, particulièrement dans un navigateur ? Si tu parles de WebSQL, le standard est en pause et rien n'indique qu'il repartira un jour.

      « Rappelez-vous toujours que si la Gestapo avait les moyens de vous faire parler, les politiciens ont, eux, les moyens de vous faire taire. » Coluche

      • [^] # Re: Mais donc les performances ?

        Posté par  . Évalué à 4.

        Je parle de faire une requête à un dataset et afficher le résultat à l'utilisateur d'un côté en C, Python ou whatever langage qui permet d'utiliser la bibliothèque SQLite directement, et de l'autre en Javascript (exécuté sur le browser ou à travers node.js, ça m'est un peu égal).

        Un use-case pourrait être de vouloir faire un peu d'exploration des données, et d'hésiter à utiliser Javascript dans une REPL, parce qu'on connaît mieux le langage mais on se demande si ça ne va pas être beaucoup plus lent.

        En réalité je suis juste curieux de savoir quel est le surcoût de la traduction llvm->javascript avec les implémentations actuelles. Et je trouve curieux que cette question ne soit pas du tout abordée par ce projet.

        • [^] # Re: Mais donc les performances ?

          Posté par  . Évalué à 4.

          Pour moi, les cas d'utilisations n'ont pas grand chose à voir. D'un côté, on fait le traitement côté serveur et de l'autre, côté client. Et vouloir faire le traitement côté client, c'est rarement pour les performances mais plutôt pour éviter une utilisation du réseau (l'application reste plus ou moins réactive avec un mauvais réseau (une fois que les données sont chargées) ou de charger le serveur. Du coup, je ne vois pas l'intérêt de comparer de le cadre du projet et je ne suis pas étonné que le projet n'en parle pas.

          « Rappelez-vous toujours que si la Gestapo avait les moyens de vous faire parler, les politiciens ont, eux, les moyens de vous faire taire. » Coluche

    • [^] # Re: Mais donc les performances ?

      Posté par  . Évalué à 4.

      Autant je trouve que ça peut être pratique dans un navigateur, puisque ça apporte des fonctionnalités non présentes, autant je ne comprends pas du tout du tout l'interêt d'avoir ça dans npm pour node.js, puisque justement on s'attent à ce que ça soit moins performant et que ça n'apporte… Rien ?

    • [^] # Re: Mais donc les performances ?

      Posté par  (site web personnel) . Évalué à 5.

      petit JSperf

      Tout ce que j’ai pour le moment, c’est cette petite page de jsperf qui indique les performances en lecture et en écriture. Comme je n’ai pas tellement travaillé sur le sujet (à part faire attention aux flags de compilation, et écrire du code propre), je ne l’ai pas mentionné ici.

      Pour les gens pressés, spoiler du jsperf ci-dessus: sur ma machine, 2500 SELECTs par seconde, et 500 INSERT par seconde.

      Idées pour le futur

      Mais l’étude des performances est très intéressante, et je vais certainement travailler dessus. J'avais par exemple envie de réécrire une partie du code de l’interface en C aussi, pour qu’il soit compilé en asm.js, et plus rapide. Dans le moteur js de firefox, les changements de contexte entre du code en asm.js et du code en javascript simple sont coûteux (ou du moins l’étaient la dernière fois que je me suis renseigné dessus). Donc il peut être intéressant d’écrire en C des routines qui vont, par exemple, préparer une requête, y attacher des paramètres, l'exécuter, et récupérer les résultats.

      Du côté d’emscripten

      En fait, j’ai un peu travaillé sur les performances, mais de manière plus générale, directement dans emscripten. Voire cette pull request, qui a maintenant été fusionnée, et qui contient de jolis graphes de performance.

      Le patch consiste à optimiser les fonctions ccall (qui permet d’appeler une fonction C compilée depuis du code javascript) et la fonction cwrap (qui retourne une fonction qui elle-même appelle la fonction C compilée). Ces deux fonctions permettent notamment de convertir les chaînes de caractères javascript en tableau de char alloués sur la pile (la pile virtuelle d’emscripten).

      • [^] # Re: Mais donc les performances ?

        Posté par  . Évalué à 5.

        Browserscope donne les performances du backend Javascript sur différent browsers, mais il manquait les comparaisons avec la bibliothèque C de départ. Je viens de m'en occuper.

        J'ai tapé "sqlite python tutorial" dans Google, choisi le premier lien venu, et adapté le code pour reproduire le code SQL utilisé sur la page JSperf. Ça donne ça:

        #!/usr/bin/python
        # -*- coding: utf-8 -*-
        
        import sqlite3 as lite
        import sys
        
        con = None
        
        # comment one or another
        #test = "insert"
        test = "select"
        
        try:
            con = lite.connect(':memory:')
        
            cur = con.cursor()
        
            cur.execute("DROP TABLE IF EXISTS t1")
            cur.execute("CREATE TABLE t1(a,b);")
            cur.execute("INSERT INTO t1 VALUES(?,?)", (1, 'hello'))
        
            if (test == "insert"):
              for i in range(250*1347):
                cur.execute("INSERT INTO t1 VALUES(?,?)", (1, 'hello'))
        
            if (test == "select"):
              for i in range(50*6795):
                cur.execute("SELECT * FROM t1").fetchall()
        
            print(cur.execute("SELECT COUNT(*) FROM t1").fetchall())
        
        except lite.Error, e:
        
            print "Error %s:" % e.args[0]
            sys.exit(1)
        
        finally:
        
            if con:
                con.close()

        J'ai pris les valeurs 1347 et 6795 qui correspondent au meilleur nombre d'opération par seconde dans le JsPerf (Firefox 30.0), et regardé par combien de fois il faut multiplier le nombre d'opération pour que le programme Python tourne en une seconde. J'ai laissé les valeurs que j'ai obtenues expérimentalement sur ma machine (en comparaison j'obtiens 1175 INSERT/s et 2542 SELECT/s sur le JsPerf):

        • le programme Python fait 250 fois plus d'itérations de INSERT en une seconde
        • le programme Python fait 50 fois plus d'itérations de SELECT en une seconde

        Ça laisse à penser qu'utiliser la lib C compilée sur ma machine, avec un frontend Python, est entre 50 et 250 fois plus rapide que d'utiliser la version compilée vers Javascript, avec un frontend Javascript—pour l'instant, on peut espérer que emscripten et les moteurs Javascript continuent à s'améliorer.

        Le benchmark pourrait être faussé par le coût de récupération des données (entre le retour de SQLite et la présentation des résultats au programme Javascript appelant). Il vaudrait mieux faire des tests sur des requêtes qui demandent plus de calcul à la base de donnée qu'au client. lovasoa, si tu es motivé…

        • [^] # Re: Mais donc les performances ?

          Posté par  . Évalué à 2.

          Pour ce genre de choses Timeit est ton BFF.

        • [^] # Re: Mais donc les performances ?

          Posté par  (site web personnel) . Évalué à 1.

          En fait, le programme python que tu présentes n’est pas équivalent au javascript dont la vitesse est mesurée dans jsperf.

          Dans le jsperf, la base est totalement détruite, et reconstruite, entre chaque requête, mais seul le temps pris par la requête (SELECT ou INSERT) est mesuré.

          Dans ton script python, tu mesures le temps pris par l’ensemble du programme (y compris le temps pris pour démarrer l’interpréteur et parser ton script), et toutes les requêtes sont faites à la suite sur la même base. C'est particulièrement dérangeant pour les INSERT, car chaque INSERT est fait sur une base un peu plus grosse. Idem pour le test JSPerf que tu as ajouté.

          De manière générale, quel que soit le langage, il vaut mieux éviter d’inventer des méthodes de test de performance si on n’a pas des notions de statistiques. JSPerf fait les choses sérieusement.

          Au final, les résultats devraient être encore plus en faveur du SQLite natif de python…

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.