Mi-décembre, Alex Tkachman et Eugene Vigdorchik ont lancé un projet appelé Groovy++ (prononcez Groovy Booster). L’idée de ce projet est de créer une version typée statiquement de Groovy afin d’obtenir de meilleures performances et la vérification à la compilation de votre programme, tout en gardant l’expressivité et la souplesse de Groovy. Cela donne aussi un boost en terme de performances, sachant que l’idée est aussi de générer du code optimisé capable d’utiliser finement les librairies Java existantes. Les auteurs parlent même de surpasser Java, ce qui semble un peu surprenant au premier abord.

Alex dit :

To make a long story short « statically typed Groovy » (aka Static Groovy, aka Groovy Booster, aka Groovy++) is an atempt to combine performace and compile-time checks with the expressiveness of the Groovy programming language and to develop high-performant runtime libraries utilizing this combination.

Il ne s’agit pas d’un nouveau langage, mais d’une option capable d’accélérer les applications Groovy comme Grails d’ailleurs. Pour cela il suffit d’ajouter un JAR et d’annoter ses classes Groovy avec @Typed pour bénéficier de l’effet Groovy++.

Voici un script Groovy écrit par Alex qui parcourt une arborescence de fichier, ouvre chacun des articles, compte le nombre d’occurence de différents mots et stocke le tout dans 2 fichiers triés par ordre croissant et décroissant. Il y a en fait 2 tâches : la collecte des mots d’une part, et le rangement d’autre part.


package org.mbte.groovypp.examples.wordcount

for (i in 0..<10) {
    t1 = System.currentTimeMillis()
    counts = [:]
    new File("./20_newsgroups").eachFileRecurse{ f ->
      if (!f.isDirectory() && !f.path.contains(".svn")) {
      f.text.toLowerCase().eachMatch(/\w+/) { w ->
          def c = counts[w] ?: 0
          counts[w] = c + 1 } } }
    new File("counts-descreasing-groovy").withWriter { out ->
      counts.sort { a, b -> b.value < => a.value }.each { k, v -> out < < "$k\t$v\n" } }
    new File("counts-alphabetical-groovy").withWriter { out ->
      counts.sort { it.key }.each { k, v -> out < < "$k\t$v\n" } }
    println "Finished in ${System.currentTimeMillis() - t1} millis"
}

D'après les tests d'Alex sur un MacBookPro, ce script tourne en moyenne en 55 secondes avec un volume de 25 Mb. Les détails et le code sont disponibles ici si vous voulez creuser un peu plus.

Avec Groovy il est possible de traiter en parrallèle la partie collecte des mots, et la partie comptage-reporting. Voici la version avec l'annotation @Type de Groovy++ qui s'exécute un peu plus rapidement, mais pas comme en 5.5 secondes comme dit Alex sur son blog. Je ne suis pas d'accord avec ses chiffres.

@Typed package org.mbte.groovypp.examples.wordcount

import java.util.concurrent.*
import groovy.util.concurrent.*

for (i in 0..<10) {

    def t1 = System.currentTimeMillis()
    def pool = Executors.newFixedThreadPool(30)
    def counts = new AtomicIntegerMap ()

    new File("./20_newsgroups").recurseFileIterator().filter{ file ->
        !file.directory && !file.path.contains(".svn")
    }.each(pool,4) { file ->
        file.charSequence.eachMatch(/\w+/) { String w -> w = w.toLowerCase()
            counts[w].incrementAndGet ()
        }
    }.whenBound {
        pool.execute {
            new File("counts-descreasing-groovy").withWriter { Writer out ->
              counts.asList().sort { a, b -> b.get() < => a.get() }.each { e -> out < < "$e.key\t${e.get()}\n" }
            }
        } {
            new File("counts-alphabetical-groovy").withWriter { Writer out ->
              counts.asList().sort { a, b -> b.key < => a.key }.each { e -> out < < "$e.key\t${e.get()}\n" }
            }
        }
        pool.shutdown()
    }

    pool.awaitTermination(30,TimeUnit.SECONDS)
    println "Finished in ${System.currentTimeMillis() - t1} millis"
}

Notez l'utilisation de la class Executors et de la mise en parrallèle du traitement. Une seule Thread principale démarre et itère chaque fichier. Une fois le traitement de celui-ci terminé, après compté le nombre de mot, la génération des rapports est déléguée à 4 threads en parrallèle. Sympa non ?

Voici enfin pour terminer la version Java:

public class JavaWordCount {
        public static void main(String[] args) throws IOException {

                for (int i = 0; i < 10; ++i) {
                        Comparator<Map.Entry<String, Integer>> sortByCount = new Comparator<Map.Entry<String, Integer>>() {
                                public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                                        return o2.getValue() - o1.getValue();
                                }
                        };
                        Comparator<Map.Entry<String, Integer>> sortByElement = new Comparator<Map.Entry<String, Integer>>() {
                                public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                                        return o1.getKey().compareTo(o2.getKey());
                                }
                        };
                        Long timeStart = System.currentTimeMillis();
                        Pattern wordPattern = Pattern.compile("\\w+");
                        File rootDir = new File("./20_newsgroups");
                        CountingSet counter = new CountingSet();
                        for (File groupDirectory : rootDir.listFiles()) {
                                if (groupDirectory.isDirectory() && !groupDirectory.getPath().contains(".svn")) {
                                        for (File f : groupDirectory.listFiles()) {
                                                if (f.isFile() && !f.getPath().contains(".svn")) {
                                                        BufferedReader reader = new BufferedReader(new FileReader(f));
                                                        String line;
                                                        while ((line = reader.readLine()) != null) {
                                                                Matcher matcher = wordPattern.matcher(line);
                                                                while (matcher.find()) {
                                                                        counter.add(matcher.group().toLowerCase());
                                                                }
                                                        }
                                                        reader.close();
                                                }
                                        }
                                }
                        }

                        Writer pw = new BufferedWriter(new PrintWriter("./counts-alphabetical-java.txt"));
                        sortAndDisplay(counter.entrySet(), sortByElement, pw);
                        pw.close();

                        pw = new BufferedWriter(new PrintWriter("./counts-decreasing-java.txt"));
                        sortAndDisplay(counter.entrySet(), sortByCount, pw);
                        pw.close();

                        System.out.println("Finished in " + (System.currentTimeMillis() - timeStart) + " ms");
                }
        }

        private static void sortAndDisplay(Set<Map.Entry<String, Integer>> set, Comparator<Map.Entry<String, Integer>> comp, Writer writer) throws IOException {
                List<Map.Entry<String, Integer>> list = new ArrayList<Map.Entry<String, Integer>>(set);
                Collections.sort(list, comp);
                display(list, writer);
        }

        private static void display(Iterable<java.util.Map.Entry<String, Integer>> list, Writer writer) throws IOException {
                for (Map.Entry<String, Integer> entry : list) {
                        writer.write(entry.getKey() + " : " + entry.getValue() + "\n");
                }
        }

        private static class CountingSet extends LinkedHashMap<String, Integer> {
                void add(String s) {
                        Integer i = get(s);
                        put(s, (i == null) ? Integer.valueOf(1) : Integer.valueOf(i + 1));
                }
        }
}

Le code Java n’est pas particulièrement optimisé, mais il a le mérite de montrer plusieurs choses. Tout d’abord l’utilisation des APIs Java, ensuite le fait que Java n’est pas adapté à ce type de traitement lorsque l’on regarde le code source. Oui cela fonctionne, mais ce n’est pas simple à lire.

Mes propres tests
Du côté des performances voici mes propres résultats avec une arborescence de fichiers Groovy d’un projet assez important.
Comptons tout d’abord sous Unix le nombre de fichiers :

nicolas@macbookpro> find . -type f  | wc -l
     2467

Cela représente un volume de 38 Mo de fichiers Java, Groovy, de pages JSP, bref un projet complet, tel qu’un outil de compilation comme Maven ou Graddle pourrait devoir traiter par exemple.

J’ai fait 3 tirs de la version Java. Chaque tir est une série de 10 tests. Le premier test est assez lent car le système fait réellement un accès disque pour charger les fichiers, le test s’exécute en 190ms en moyenne pour la version Java. J’ai donc supprimé à chaque fois pour l’ensemble de mes tests la valeur la plus lente et la valeur la plus rapide, afin de lisser les échantillons de tests.

Voici le script de la version Groovy++ que j’ai légèrement modifié :

@Typed(TypePolicy.MIXED)
package test
class Boosted {
    static main(args){
		for (i in 0..<10) {
		    def t1 = System.currentTimeMillis()
		    def counts = [:]
		    new File(".").eachFileRecurse
		    {
			  f ->
		      if (!f.isDirectory() && !f.path.contains(".svn"))
		      {
		        f.text.toLowerCase().eachMatch(/\w+/) { w ->
		          def c = counts[w] ?: 0
		          counts[w] = c + 1
		          }
		       }
		    }
		    new File("counts-descreasing-groovy").withWriter { out ->
		      counts.sort { a, b -> b.value < => a.value }.each { k, v -> out < < "$k\t$v\n" } }
		    new File("counts-alphabetical-groovy").withWriter { out ->
		      counts.sort { it.key }.each { k, v -> out < < "$k\t$v\n" } }
		    println "${System.currentTimeMillis() - t1}"
		}
  	}
}

Groovy++ est distribué sous la forme d’un ZIP. Il suffit de le décompresser, de remplacer sa variable GROOVY_HOME pour qu’elle pointe vers cette version, de mettre à jour son PATH et c’est tout. J’ai eu à augmenter les options Java pour résoudre un problème de OutOfMemory (export JAVA_OPTS= »-Xmx512m ») mais à part cela, c’est du Groovy classique.

Voici la moyenne des résultats ma machine (MacBookPro 2.4Ghz Intel Core 2 Duo, 4GB) pour la version Java, la verson Groovy et la version Groovy++

Java 1.6.0.17 64-bits : 58,71 ms
Groovy 1.6.0 : 5839,58 ms
Groovy ++ 0.1.12: 3858,75 ms

Mes propres tests montrent que la version Groovy++ est plus rapide de 34% par rapport à la version Groovy. Java reste largement plus rapide que la version Groovy et la version Groovy++. Tout ceci est fait avec un micro-test, ce qui n’est pas suffisant pour dire que l’un est plus rapide que l’autre. Mais cela me refait penser à Graddle, un outil de build Groovy, très sympa à utiliser, mais pour lequel j’avais une réserve à priori, sans avoir effectué de tests.

Voyons enfin ce que cela donne avec la version 4 Threads d’Alex. Le script est modifié afin d’effectuer la partie reporting en parrallèle.

@Typed(TypePolicy.MIXED)
package test

import java.util.concurrent.*
import groovy.util.concurrent.*

class BoostedOptimized {
    static main(args){
		for (i in 0..<10) {

		    def t1 = System.currentTimeMillis()
		    def pool = Executors.newFixedThreadPool(30)
		    def counts = new AtomicIntegerMap ()

		    new File(".").recurseFileIterator().filter{ file ->
		        !file.directory && !file.path.contains(".svn")
		    }.each(pool,4) { file ->
		        file.charSequence.eachMatch(/\w+/) { String w -> w = w.toLowerCase()
		            counts[w].incrementAndGet ()
		        }
		    }.whenBound {
		        pool.execute {
		            new File("counts-descreasing-groovy").withWriter { Writer out ->
		              counts.asList().sort { a, b -> b.get() <=> a.get() }.each { e -> out << "$e.key\t${e.get()}\n" }
		            }
		        } {
		            new File("counts-alphabetical-groovy").withWriter { Writer out ->
		              counts.asList().sort { a, b -> b.key <=> a.key }.each { e -> out << "$e.key\t${e.get()}\n" }
		            }
		        }
		        pool.shutdown()
		    }

		    pool.awaitTermination(30,TimeUnit.SECONDS)
		    println "${System.currentTimeMillis() - t1}"
		}
  	}
}

J’ai effectué 3 séries de 10 tests, voici la synthèse:
Java 1.6.0.17 64-bits : 58,71 ms
Groovy 1.6.0 : 5839,58 ms
Groovy ++ 0.1.12 : 3858,75 ms
Groovy++ 4 Threads : 2129,17ms

La version 4 Threads est donc 63,54% plus rapide que la version Groovy de base. Cependant je pense que c’est une erreur de les comparer, l’algo étant différent. Nous devons simplement retenir l’écart entre la version Groovy standard et la version exécutée avec Groovy Booster qui est de 33%. C’est déjà beaucoup.

Groovy++ est un projet intéressant, qui montre qu’il est possible d’accélérer Groovy. Je serai intéressé de savoir le retour de Guillaume Laforge sur le sujet. Il faut aussi noter qu’il y a quelques bémols, l’utilisation de Groovy++ empêche d’utiliser quelques mots clés comme l’opérateur de transtypage « as » (« -1 » as Integer par exemple).

Enfin pour terminer, d’après les billets que j’ai vu, la version Scala est exactement aussi rapide que la version Java, il n’y a aucunes différences. C’est aussi l’avantage d’un langage avec typage fort, nous reparlerons de Scala bientôt.

Les tableaux de résultat :

Sources
Mail d’Alex à la liste Grails
First preview of static compiler for Groovy is available
How come that Groovy++ overperform Java?

8 réflexions sur « Groovy++, une version typée statiquement de Groovy »

  1. @Typed ça me rappel le option explicit en VB XD

    Mais plus sérieusement, je trouve ça un peu biaisé comme test. La version groovy++ est multi-threadée, donc ce n’est pas comparable. Clairement Java est plus rapide (oh ! surprise !), mais il faudrait comparer Groovy++ et Groovy « normal » sans introduire de MT, non ?

  2. @Waddle : attention j’ai mis à jour l’article suite à ta remarque. J’ai fait la distinction entre la version multi-threadée et la version normale. Le 34% de gain de vitesse concerne bien la version classique, pas la version 4 Threads. Je suis d’accord que son test de départ était biaisé, c’est pour cela que je les ai tous refait.

  3. J’ai du mal à comprendre la motivation de tant de complications à utiliser un langage dynamique pour revenir finalement à un aspect statique, après avoir gagné certes 34%, mais perdu quasiment un facteur 30 en vitesse par rapport à Java !!! Entre temps, on a créé une usine à gaz, pour un gain qui me semble de plus en plus faible.

    Cela me fait un peu penser aux problèmes qu’on rencontre avec Maven parfois : J’ai l’impression que ça plaît à certaines personnes de résoudre des problèmes qu’ils se sont créés avec des outils inadaptés et où ils passent finalement plus de temps à résoudre les problèmes de l’outil plutôt qu’en gagner avec celui-ci. Je pense que de temps en temps, un bon vieux retour à l’Analyse de la Valeur s’imposerait à tous.

  4. J’aurais mis sortByCount/sortByElement en static final, et avec des « ? extends Comparable » plutôt que de préciser String-Integer.

  5. Comment expliquer que Groovy++ soit moins rapide que Java ? N\’est ce pas simplement lié à la jeunesse du compilateur ? Théoriquement, le code écrit en Groovy++ et en Java, ne devrait il pas se traduire en un ByteCode *semblable*, et donc en des performances proche, *comme en Scala* (notez les étoiles 😉 ) ?

    A partir du moment où le langage est typé pareil que Java, il n\’y a pas vraiment de raison que les performances soient à ce point plus mauvaise, on pourrait d\’ailleurs imaginer compiler n\’importe quoi en ByteCode (Du C++, du Haskell …)

  6. A Stéphane Vanacker

    Le besoin d’un langage comme Groovy se ferait considérablement moins sentir si, en Java, on disposait d’une puissance d’expression équivalente. Si, pour exprimer 16 lignes de code Groovy en Java, il ne faut pas moins de 67 lignes de code (sans commentaires), et que, malgré cela, le code n’est pas beaucoup plus facile à lire, c’est bien que Groovy répond effectivement à un réel besoin…

    Je suis d’accord avec vous sur le fait qu’essayer d’introduire du typage statique (sans changer la syntaxe !) dans un langage qui n’est pas du tout fait pour cela, c’est sans doute une usine à gaz… surtout quand on ne rapproche pas vraiment des performances d’un vrai langage typé statiquement (puisqu’on reste 40 fois plus lent que Java au lieu de 60 fois : si on se retrouvait à être 2 ou 3 fois plus lent que Java, là ça vaudrait clairement le coup).

    Bref, tout ça ne répond pas au besoin précédemment exprimé. Et est-ce qu’introduire ces fameuses closures dans Java, qui sont à l’origine de la puissance d’expression de Groovy (c’est particulièrement visible dans cet exemple), ça ne serait pas plutôt ça, la solution ?

  7. Guys (comme disent les américains), Guys, n’oubliez pas que tout ceci est basé sur un micro-benchmark, qui fait essentiellement de l’accès disque, du traitement de collection et de l’écriture de 2 fichiers. Ce n’est pas révélateur et il ne faut pas conclure rapidement que Groovy est plus lent que Java. D’autres tests seront plus intéressants à vous montrer, où vous verrez que les performances sont identiques, pour cependant une quantité de lignes de codes et une clarté clairement en faveur de Groovy.
    Restez dans le coin, on reparlera du sujet bientôt.

Les commentaires sont fermés.