web-dev-qa-db-fra.com

Coût caché des performances à Scala?

Je suis tombé sur cette ancienne question et j'ai fait l'expérience suivante avec scala 2.10.3.

J'ai réécrit la version Scala pour utiliser la récursivité explicite de la queue:

import scala.annotation.tailrec

object ScalaMain {
  private val t = 20

  private def run() {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

et je l'ai comparé à la version Java version. J'ai consciemment rendu les fonctions non statiques pour une comparaison équitable avec Scala:

public class JavaMain {
    private final int t = 20;

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

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b) return true;
        else return (a % i == 0) && isEvenlyDivisible(i+1, a, b);
    }

    public static void main(String[] args) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
          o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

Voici les résultats sur mon ordinateur:

> Java JavaMain
....
time: 9651
> scala ScalaMain
....
time: 20592

Il s'agit de scala 2.10.3 sur (Java HotSpot (TM) 64-Bit Server VM, Java 1.7.0_51).

Ma question est quel est le coût caché avec la version scala?

Merci beaucoup.

55
Phil

Eh bien, l'analyse comparative d'OP n'est pas l'idéal. Des tonnes d'effets doivent être atténués, y compris l'échauffement, l'élimination du code mort, la fourche, etc. Heureusement, JMH s'occupe déjà de beaucoup de choses et a des liaisons pour les deux Java et Scala. Veuillez suivre les procédures sur la page JMH pour obtenir le projet de référence, puis vous pouvez transplanter les références ci-dessous.

Voici l'exemple Java benchmark:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
public class JavaBench {

    @Param({"1", "5", "10", "15", "20"})
    int t;

    private int run() {
        int i = 10;
        while(!isEvenlyDivisible(2, i, t))
            i += 2;
        return i;
    }

    private boolean isEvenlyDivisible(int i, int a, int b) {
        if (i > b)
            return true;
        else
            return (a % i == 0) && isEvenlyDivisible(i + 1, a, b);
    }

    @GenerateMicroBenchmark
    public int test() {
        return run();
    }

}

... et voici l'exemple Scala benchmark:

@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
class ScalaBench {

  @Param(Array("1", "5", "10", "15", "20"))
  var t: Int = _

  private def run(): Int = {
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    i
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i + 1, a, b)
  }

  @GenerateMicroBenchmark
  def test(): Int = {
    run()
  }

}

Si vous les exécutez sur JDK 8 GA, Linux x86_64, vous obtiendrez:

Benchmark             (t)   Mode   Samples         Mean   Mean error    Units
o.s.ScalaBench.test     1   avgt        15        0.005        0.000    us/op
o.s.ScalaBench.test     5   avgt        15        0.489        0.001    us/op
o.s.ScalaBench.test    10   avgt        15       23.672        0.087    us/op
o.s.ScalaBench.test    15   avgt        15     3406.492        9.239    us/op
o.s.ScalaBench.test    20   avgt        15  2483221.694     5973.236    us/op

Benchmark            (t)   Mode   Samples         Mean   Mean error    Units
o.s.JavaBench.test     1   avgt        15        0.002        0.000    us/op
o.s.JavaBench.test     5   avgt        15        0.254        0.007    us/op
o.s.JavaBench.test    10   avgt        15       12.578        0.098    us/op
o.s.JavaBench.test    15   avgt        15     1628.694       11.282    us/op
o.s.JavaBench.test    20   avgt        15  1066113.157    11274.385    us/op

Remarquez que nous jonglons avec t pour voir si l'effet est local pour la valeur particulière de t. Ce n'est pas le cas, l'effet est systématique, et Java étant deux fois plus rapide.

PrintAssembly nous éclairera à ce sujet. Celui-ci est le bloc le plus chaud de Scala benchmark:

0x00007fe759199d42: test   %r8d,%r8d
0x00007fe759199d45: je     0x00007fe759199d76  ;*irem
                                               ; - org.sample.ScalaBench::isEvenlyDivisible@11 (line 52)
                                               ; - org.sample.ScalaBench::run@10 (line 45)
0x00007fe759199d47: mov    %ecx,%eax
0x00007fe759199d49: cmp    $0x80000000,%eax
0x00007fe759199d4e: jne    0x00007fe759199d58
0x00007fe759199d50: xor    %edx,%edx
0x00007fe759199d52: cmp    $0xffffffffffffffff,%r8d
0x00007fe759199d56: je     0x00007fe759199d5c
0x00007fe759199d58: cltd   
0x00007fe759199d59: idiv   %r8d

... et c'est un bloc similaire en Java:

0x00007f4a811848cf: movslq %ebp,%r10
0x00007f4a811848d2: mov    %ebp,%r9d
0x00007f4a811848d5: sar    $0x1f,%r9d
0x00007f4a811848d9: imul   $0x55555556,%r10,%r10
0x00007f4a811848e0: sar    $0x20,%r10
0x00007f4a811848e4: mov    %r10d,%r11d
0x00007f4a811848e7: sub    %r9d,%r11d         ;*irem
                                              ; - org.sample.JavaBench::isEvenlyDivisible@9 (line 63)
                                              ; - org.sample.JavaBench::isEvenlyDivisible@19 (line 63)
                                              ; - org.sample.JavaBench::run@10 (line 54)

Remarquez comment dans la version Java, le compilateur a utilisé l'astuce pour traduire le calcul du reste entier en multiplication et en se déplaçant vers la droite (voir Hacker's Delight, Ch. 10, Sect. 19). Ceci est possible lorsque le compilateur détecte nous calculons le reste par rapport à la constante, ce qui suggère Java a atteint cette douce optimisation, mais Scala ne l'a pas fait. Vous pouvez creuser dans le démontage du bytecode pour comprendre ce qui est intervenu dans scalac est intervenu, mais le but de cet exercice est que les différences minuscules surprenantes dans la génération de code sont amplifiées par des repères beaucoup.

P.S. Tant pour @tailrec...

MISE À JOUR: Une explication plus approfondie de l'effet: http://shipilev.net/blog/2014/Java-scala-divided-we-fail/

125
Aleksey Shipilev

J'ai changé le val

private val t = 20

à une définition constante

private final val t = 20

et a obtenu une augmentation significative des performances, il semble maintenant que les deux versions fonctionnent presque également [sur mon système, voir la mise à jour et les commentaires].

Je n'ai pas examiné le bytecode, mais si vous utilisez val t = 20 vous pouvez voir en utilisant javap qu'il existe une méthode (et que la version est aussi lente que celle avec le private val).

Je suppose donc que même un private val implique l'appel d'une méthode, et ce n'est pas directement comparable à un final en Java.

Mise à jour

Sur mon système, j'ai obtenu ces résultats

Version Java: temps: 14725

Version Scala: temps: 13228

Utilisation d'OpenJDK 1.7 sur un Linux 32 bits.

D'après mon expérience, le JDK d'Oracle sur un système 64 bits fonctionne réellement mieux, donc cela explique probablement que d'autres mesures donnent des résultats encore meilleurs en faveur de la version Scala.

Quant à la version Scala fonctionnant mieux, je suppose que l'optimisation de la récursivité de queue a un effet ici (voir la réponse de Phil, si la version Java Java est réécrite pour utiliser un boucle au lieu de récursivité, il fonctionne à nouveau également).

23
Beryllium

J'ai regardé cette question et édité la version Scala pour avoir t à l'intérieur run:

object ScalaMain {
  private def run() {
    val t = 20
    var i = 10
    while(!isEvenlyDivisible(2, i, t))
      i += 2
    println(i)
  }

  @tailrec private def isEvenlyDivisible(i: Int, a: Int, b: Int): Boolean = {
    if (i > b) true
    else (a % i == 0) && isEvenlyDivisible(i+1, a, b)
  }

  def main(args: Array[String]) {
    val t1 = System.currentTimeMillis()
    var i = 0
    while (i < 20) {
      run()
      i += 1
    }
    val t2 = System.currentTimeMillis()
    println("time: " + (t2 - t1))
  }
}

La nouvelle version Scala s'exécute désormais deux fois plus vite que la version originale Java:

> fsc ScalaMain.scala
> scala ScalaMain
....
time: 6373
> fsc -optimize ScalaMain.scala
....
time: 4703

J'ai compris que c'était parce que Java n'avait pas d'appels de queue. Le Java optimisé avec boucle au lieu de récursivité s'exécute tout aussi rapidement:

public class JavaMain {
    private static final int t = 20;

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

    private 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) {
        JavaMain o = new JavaMain();
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 20; ++i)
            o.run();
        long t2 = System.currentTimeMillis();
        System.out.println("time: " + (t2 - t1));
    }
}

Maintenant, ma confusion est complètement résolue:

> Java JavaMain
....
time: 4795

En conclusion, la version originale Scala était lente car je n'ai pas déclaré t comme final (directement ou indirectement, comme Beryllium 's - réponse souligne). Et la version originale Java était lente en raison du manque d'appels de queue.

7
Phil

Pour rendre la version Java complètement équivalente à votre code Scala, vous devez la changer comme ceci.).

private int t = 20;


private int t() {
    return this.t;
}

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

Il est plus lent car la JVM ne peut pas optimiser les appels de méthode.

1
SpiderPig