web-dev-qa-db-fra.com

Comment optimiser les for-comprehensions et les boucles dans Scala?

Donc Scala est censé être aussi rapide que Java. Je revisite certains problèmes Project Euler dans Scala que j'ai abordé à l'origine) en Java. Plus précisément le problème 5: "Quel est le plus petit nombre positif divisible par tous les nombres de 1 à 20?"

Voici ma Java, qui prend 0,7 seconde pour terminer sur ma machine:

public class P005_evenly_divisible implements Runnable{
    final int t = 20;

    public void run() {
        int i = 10;
        while(!isEvenlyDivisible(i, t)){
            i += 2;
        }
        System.out.println(i);
    }

    boolean isEvenlyDivisible(int a, int b){
        for (int i = 2; i <= b; i++) {
            if (a % i != 0) 
                return false;
        }
        return true;
    }

    public static void main(String[] args) {
        new P005_evenly_divisible().run();
    }
}

Voici ma "traduction directe" en Scala, qui prend 103 secondes (147 fois plus!)

object P005_JavaStyle {
    val t:Int = 20;
    def run {
        var i = 10
        while(!isEvenlyDivisible(i,t))
            i += 2
        println(i)
    }
    def isEvenlyDivisible(a:Int, b:Int):Boolean = {
        for (i <- 2 to b)
            if (a % i != 0)
                return false
        return true
    }
    def main(args : Array[String]) {
        run
    }
}

Enfin, voici ma tentative de programmation fonctionnelle, qui prend 39 secondes (55 fois plus)

object P005 extends App{
    def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
    def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
    println (find (2))
}

Utilisation de Scala 2.9.0.1 sur Windows 7 64 bits. Comment améliorer les performances? Suis-je en train de faire quelque chose de mal? Ou est Java juste beaucoup plus rapide?

131

Le problème dans ce cas particulier est que vous revenez de l'intérieur de for-expression. Cela à son tour se traduit par un jet d'une NonLocalReturnException, qui est interceptée à la méthode englobante. L'optimiseur peut éliminer le foreach mais ne peut pas encore éliminer le lancer/rattraper. Et lancer/attraper coûte cher. Mais comme ces retours imbriqués sont rares dans les programmes Scala, l'optimiseur n'a pas encore résolu ce cas. Des travaux sont en cours pour améliorer l'optimiseur qui, espérons-le, résoudra bientôt ce problème.

111
Martin Odersky

Le problème est très probablement l'utilisation d'une compréhension for dans la méthode isEvenlyDivisible. Le remplacement de for par une boucle équivalente while devrait éliminer la différence de performances avec Java.

Contrairement aux boucles for de Java, les compréhensions for de Scala sont en fait du sucre syntaxique pour les méthodes d'ordre supérieur; dans ce cas, vous appelez la méthode foreach sur un objet Range. Le for de Scala est très général, mais conduit parfois à des performances douloureuses.

Vous voudrez peut-être essayer le -optimize flag in Scala version 2.9. Les performances observées peuvent dépendre de la machine virtuelle Java particulière utilisée et de l'optimiseur JIT ayant suffisamment de temps de "réchauffement" pour identifier et optimiser les points chauds.

Des discussions récentes sur la liste de diffusion indiquent que l'équipe Scala travaille à l'amélioration des performances de for dans des cas simples:

Voici le problème dans le suivi des bogues: https://issues.scala-lang.org/browse/SI-46

Mise à jour 5/28 :

  • En tant que solution à court terme, le plugin ScalaCL (alpha) transformera les boucles simples Scala en l'équivalent des boucles while.
  • En tant que solution potentielle à plus long terme, les équipes de l'EPFL et de Stanford collaborant sur un projet permettent la compilation au moment de l'exécution de Scala "virtuel" pour des performances très élevées. Par exemple, plusieurs boucles fonctionnelles idiomatiques peuvent être fusionnées au moment de l'exécution en bytecode JVM optimal, ou vers une autre cible telle qu'un GPU. Le système est extensible, permettant des DSL et des transformations définies par l'utilisateur. Consultez les publications et Stanford notes de cours . Le code préliminaire est disponible sur Github, avec une sortie prévue dans les prochains mois.
80
Kipton Barros

En guise de suivi, j'ai essayé l'indicateur -optimize et il a réduit le temps d'exécution de 103 à 76 secondes, mais c'est toujours 107x plus lent que Java ou une boucle while.

Ensuite, je regardais la version "fonctionnelle":

object P005 extends App{
  def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

et essayer de comprendre comment se débarrasser du "forall" de manière concise. J'ai lamentablement échoué et j'ai trouvé

object P005_V2 extends App {
  def isDivis(x:Int):Boolean = {
    var i = 1
    while(i <= 20) {
      if (x % i != 0) return false
      i += 1
    }
    return true
  }
  def find(n:Int):Int = if (isDivis(n)) n else find (n+2)
  println (find (2))
}

grâce à quoi ma solution astucieuse de 5 lignes est passée à 12 lignes. Cependant, cette version s'exécute en 0,71 seconde , à la même vitesse que l'original Java, et 56 fois plus rapide que la version ci-dessus utilisant "forall" (40,2 s)! (voir EDIT ci-dessous pour savoir pourquoi c'est plus rapide que Java)

Évidemment, ma prochaine étape consistait à traduire ce qui précède en Java, mais Java ne peut pas le gérer et lance une StackOverflowError avec n autour de la marque 22000.

J'ai ensuite gratté la tête un peu et remplacé le "while" par un peu plus de récursion de queue, ce qui économise quelques lignes, fonctionne tout aussi vite, mais avouons-le, est plus déroutant à lire:

object P005_V3 extends App {
  def isDivis(x:Int, i:Int):Boolean = 
    if(i > 20) true
    else if(x % i != 0) false
    else isDivis(x, i+1)

  def find(n:Int):Int = if (isDivis(n, 2)) n else find (n+2)
  println (find (2))
}

Donc la récursion de la queue de Scala gagne la journée, mais je suis surpris que quelque chose d'aussi simple qu'une boucle "for" (et la méthode "forall") soit essentiellement cassé et doive être remplacé par des "whiles" inélégants et verbeux, ou récursion de la queue . Une grande partie de la raison pour laquelle j'essaie Scala est à cause de la syntaxe concise, mais ce n'est pas bon si mon code va s'exécuter 100 fois plus lentement!

[~ # ~] modifier [~ # ~] : (supprimé)

EDIT OF EDIT : les anciennes différences entre les temps d'exécution de 2,5 s et 0,7 s étaient entièrement dues au fait que les JVM 32 bits ou 64 bits étaient utilisées . Scala à partir de la ligne de commande utilise tout ce qui est défini par Java_HOME, tandis que Java utilise 64 bits si disponible malgré tout. Les IDE ont leurs propres paramètres. Quelques mesures ici: temps d'exécution Scala dans Eclipse

La réponse concernant la compréhension est juste, mais ce n'est pas toute l'histoire. Vous devez noter que l'utilisation de return dans isEvenlyDivisible n'est pas gratuite. L'utilisation de return à l'intérieur de for, force le compilateur scala à générer un retour non local (c'est-à-dire à retourner en dehors de sa fonction).

Cela se fait grâce à l'utilisation d'une exception pour quitter la boucle. La même chose se produit si vous créez vos propres abstractions de contrôle, par exemple:

def loop[T](times: Int, default: T)(body: ()=>T) : T = {
    var count = 0
    var result: T = default
    while(count < times) {
        result = body()
        count += 1
    }
    result
}

def foo() : Int= {
    loop(5, 0) {
        println("Hi")
        return 5
    }
}

foo()

Cela imprime "Salut" une seule fois.

Notez que return dans foo quitte foo (ce que vous attendez). Puisque l'expression entre crochets est un littéral de fonction, que vous pouvez voir dans la signature de loop, cela oblige le compilateur à générer un retour non local, c'est-à-dire que le return vous oblige à quitter foo, pas seulement le body.

Dans Java (c'est-à-dire la JVM), la seule façon de mettre en œuvre un tel comportement est de lever une exception.

Pour revenir à isEvenlyDivisible:

def isEvenlyDivisible(a:Int, b:Int):Boolean = {
  for (i <- 2 to b) 
    if (a % i != 0) return false
  return true
}

La if (a % i != 0) return false est un littéral de fonction qui a un retour, donc à chaque fois que le retour est frappé, le runtime doit lever et intercepter une exception, ce qui provoque pas mal de surcharge GC.

8
juancn

Quelques façons d'accélérer la méthode forall que j'ai découverte:

L'original: 41,3 s

def isDivis(x:Int) = (1 to 20) forall {x % _ == 0}

Pré-instancier la plage, donc nous ne créons pas une nouvelle plage à chaque fois: 9,0 s

val r = (1 to 20)
def isDivis(x:Int) = r forall {x % _ == 0}

Conversion en liste au lieu d'une plage: 4,8 s

val rl = (1 to 20).toList
def isDivis(x:Int) = rl forall {x % _ == 0}

J'ai essayé quelques autres collections, mais List était le plus rapide (bien que toujours 7 fois plus lent que si nous évitions la fonction Range et d'ordre supérieur).

Bien que je sois nouveau à Scala, je suppose que le compilateur pourrait facilement implémenter un gain de performances rapide et significatif en remplaçant simplement automatiquement les littéraux Range dans les méthodes (comme ci-dessus) par des constantes Range dans la portée la plus externe. Ou mieux, internez-les comme des littéraux Strings en Java.


note de bas de page: les tableaux étaient à peu près les mêmes que Range, mais de manière intéressante, le proxénétisme d'une nouvelle méthode forall (illustrée ci-dessous) a permis une exécution 24% plus rapide sur 64 bits et 8% plus rapide sur 32 bits. Lorsque j'ai réduit la taille du calcul en réduisant le nombre de facteurs de 20 à 15, la différence a disparu, alors c'est peut-être un effet de collecte des ordures. Quelle que soit la cause, elle est importante lors d'un fonctionnement à pleine charge pendant de longues périodes.

Un proxénète similaire pour List a également permis d'améliorer les performances d'environ 10%.

  val ra = (1 to 20).toArray
  def isDivis(x:Int) = ra forall2 {x % _ == 0}

  case class PimpedSeq[A](s: IndexedSeq[A]) {
    def forall2 (p: A => Boolean): Boolean = {      
      var i = 0
      while (i < s.length) {
        if (!p(s(i))) return false
        i += 1
      }
      true
    }    
  }  
  implicit def arrayToPimpedSeq[A](in: Array[A]): PimpedSeq[A] = PimpedSeq(in)  

Je voulais juste faire un commentaire pour les personnes qui pourraient perdre confiance en Scala sur des problèmes comme celui-ci, que ces types de problèmes surviennent dans la performance de presque tous les langages fonctionnels. Si vous optimisez un repli dans Haskell, vous devrez souvent le réécrire en tant que boucle récursive optimisée pour les appels de queue, sinon vous aurez des problèmes de performances et de mémoire à gérer.

Je sais que c'est dommage que les FP ne soient pas encore optimisés au point où nous n'avons pas à penser à des choses comme ça, mais ce n'est pas du tout un problème particulier à Scala.

3
Ara Vartanian

Des problèmes spécifiques à Scala ont déjà été discutés, mais le problème principal est que l'utilisation d'un algorithme de force brute n'est pas très cool. Considérez ceci (beaucoup plus rapide que l'original Java code):

def gcd(a: Int, b: Int): Int = {
    if (a == 0)
        b
    else
        gcd(b % a, a)
}
print (1 to 20 reduce ((a, b) => {
  a / gcd(a, b) * b
}))
2
Sarge Borsch

Essayez le one-liner donné dans la solution Scala pour Project Euler

Le temps donné est au moins plus rapide que le vôtre, bien que loin de la boucle while .. :)

1
eivindw