Wie optimiert man For-Comprehensions und Schleifen in Scala?
Lesezeit: 12 Minuten
Luigi Pling
Scala soll also so schnell sein wie Java. Ich greife einige wieder auf Projekt Euler Probleme in Scala, die ich ursprünglich in Java angegangen bin. Insbesondere Aufgabe 5: “Was ist die kleinste positive Zahl, die durch alle Zahlen von 1 bis 20 ohne Rest teilbar ist?”
Hier ist meine Java-Lösung, die auf meinem Computer 0,7 Sekunden dauert:
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();
}
}
Hier ist meine “direkte Übersetzung” in Scala, die 103 Sekunden dauert (147-mal länger!)
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
}
}
Hier ist endlich mein Versuch der funktionalen Programmierung, der 39 Sekunden dauert (55-mal länger)
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))
}
Verwenden von Scala 2.9.0.1 unter Windows 7 64-Bit. Wie verbessere ich die Leistung? Mache ich etwas falsch? Oder ist Java einfach viel schneller?
kompilieren oder interpretieren Sie mit der Scala-Shell?
– ahmet alp balkan
26. Mai 2011 um 23:23 Uhr
Es gibt einen besseren Weg, dies zu tun, als die Trial-Division (Hinweis).
– Hammar
26. Mai 2011 um 23:41 Uhr
du zeigst nicht, wie du das timst. Haben Sie versucht, nur das Timing der run Methode?
– Aaron Novstrup
26. Mai 2011 um 23:57 Uhr
@hammar – ja, habe es einfach mit Stift und Papier gemacht: Schreiben Sie die Primfaktoren für jede Zahl auf, beginnend mit hoch, und streichen Sie dann die Faktoren durch, die Sie bereits für höhere Zahlen haben, sodass Sie mit (5 * 2 * 2) abschließen. *(19)*(3*3)*(17)*(2*2)*()*(7)*(13)*()*(11) = 232792560
– Luigi Plinge
27. Mai 2011 um 1:43 Uhr
+1 Dies ist die interessanteste Frage, die ich seit Wochen auf SO gesehen habe (das hat auch die beste Antwort, die ich seit langem gesehen habe).
– Mia Clarke
28. Mai 2011 um 7:46 Uhr
Martin Odersky
Das Problem in diesem speziellen Fall ist, dass Sie innerhalb des for-Ausdrucks zurückkehren. Dies wird wiederum in einen Wurf einer NonLocalReturnException übersetzt, die von der einschließenden Methode abgefangen wird. Der Optimierer kann foreach eliminieren, kann aber noch nicht throw/catch eliminieren. Und Werfen/Fangen ist teuer. Da solche verschachtelten Rückgaben in Scala-Programmen jedoch selten sind, hat der Optimierer diesen Fall noch nicht behandelt. Es wird daran gearbeitet, den Optimierer zu verbessern, der dieses Problem hoffentlich bald lösen wird.
Ziemlich heftig, dass eine Retoure zur Ausnahme wird. Ich bin mir sicher, dass es irgendwo dokumentiert ist, aber es stinkt nach unverständlicher verborgener Magie. Ist das wirklich der einzige Weg?
– skrebbel
16. Juni 2011 um 14:13 Uhr
Wenn die Rückgabe aus einem Verschluss erfolgt, scheint dies die beste verfügbare Option zu sein. Returns von Outside Closures werden natürlich direkt in Return-Instruktionen im Bytecode übersetzt.
– Martin Odersky
16. Juni 2011 um 15:09 Uhr
Ich bin mir sicher, dass ich etwas übersehe, aber warum kompilieren Sie nicht stattdessen die Rückgabe aus einem Abschluss, um ein eingeschlossenes boolesches Flag und einen Rückgabewert zu setzen, und überprüfen Sie dies, nachdem der Abschlussaufruf zurückgegeben wurde?
– Lukas Huttemann
16. Juni 2011 um 15:55 Uhr
Warum ist sein Funktionsalgorithmus immer noch 55-mal langsamer? Es sieht nicht so aus, als ob es unter einer so schrecklichen Leistung leiden sollte
– Elia
16. Juni 2011 um 20:10 Uhr
Jetzt, im Jahr 2014, habe ich dies erneut getestet und für mich ist die Leistung wie folgt: java -> 0,3 s; Skala -> 3,6 s; Scala optimiert -> 3,5s; Scala funktionsfähig -> 4s; Sieht viel besser aus als vor 3 Jahren, aber… Trotzdem ist der Unterschied zu groß. Können wir weitere Leistungsverbesserungen erwarten? Mit anderen Worten, Martin, gibt es theoretisch noch etwas für mögliche Optimierungen?
– sasha.sochka
23. April 2014 um 18:38 Uhr
Kippon Barros
Das Problem ist höchstwahrscheinlich die Verwendung von a for Verständnis in der Methode isEvenlyDivisible. Ersetzen for durch ein Äquivalent while Schleife sollte den Leistungsunterschied zu Java eliminieren.
Im Gegensatz zu Java for Schleifen, Scalas for Verständnisse sind eigentlich syntaktischer Zucker für Methoden höherer Ordnung; In diesem Fall rufen Sie die an foreach Methode auf a Range Objekt. Scalas for ist sehr allgemein, führt aber manchmal zu einer schmerzhaften Leistung.
Vielleicht möchten Sie die ausprobieren -optimize Flag in Scala-Version 2.9. Die beobachtete Leistung kann von der jeweils verwendeten JVM abhängen und davon, dass der JIT-Optimierer ausreichend “Aufwärmzeit” hat, um Hotspots zu identifizieren und zu optimieren.
Aktuelle Diskussionen auf der Mailingliste zeigen, dass das Scala-Team an Verbesserungen arbeitet for Leistung in einfachen Fällen:
Als kurzfristige Lösung bietet die ScalaCL plugin (Alpha) verwandelt einfache Scala-Loops in das Äquivalent von while Schleifen.
Als potenzielle längerfristige Lösung gelten Teams von der EPFL und Stanford an einem Projekt mitarbeiten Aktivierung der Laufzeitkompilierung von “virtuelle” Scala für sehr hohe Leistung. Beispielsweise können mehrere idiomatische Funktionsschleifen sein zur Laufzeit verschmolzen in den optimalen JVM-Bytecode oder an ein anderes Ziel wie eine GPU. Das System ist erweiterbar und ermöglicht benutzerdefinierte DSLs und Transformationen. Probier das aus Veröffentlichungen und Stanford Kursnotizen. Vorläufiger Code ist auf Github verfügbar, eine Veröffentlichung ist in den kommenden Monaten geplant.
Toll, ich habe die for-Verständnis durch eine while-Schleife ersetzt und sie läuft genauso schnell (+/- < 1%) wie die Java-Version. Danke ... Ich habe fast für eine Minute den Glauben an Scala verloren! Jetzt muss nur noch an einem guten Funktionsalgorithmus gearbeitet werden ... :)
– Luigi Plinge
27. Mai 2011 um 1:11 Uhr
Es ist erwähnenswert, dass endrekursive Funktionen auch so schnell sind wie While-Schleifen (da beide in sehr ähnlichen oder identischen Bytecode konvertiert werden).
– Rex Kerr
27. Mai 2011 um 2:03 Uhr
Das hat mich auch einmal erwischt. Musste wegen unglaublicher Verlangsamung einen Algorithmus von der Verwendung von Sammlungsfunktionen auf verschachtelte While-Schleifen (Level 6!) Übersetzen. Dies ist etwas, das stark zielgerichtet sein muss, imho; was nützt ein netter Programmierstil, wenn ich ihn nicht verwenden kann, wenn ich anständige (Achtung: nicht blitzschnelle) Leistung brauche?
– Raffael
27. Mai 2011 um 18:22 Uhr
Wann ist for dann geeignet?
– Oscar Ryz
16. Juni 2011 um 1:41 Uhr
@OscarRyz – a for in Scala verhält sich größtenteils wie for (:) in Java.
– Mike Axiak
16. Juni 2011 um 12:28 Uhr
Luigi Pling
Als Folge habe ich das -optimize-Flag ausprobiert und es hat die Laufzeit von 103 auf 76 Sekunden reduziert, aber das ist immer noch 107x langsamer als Java oder eine While-Schleife.
Dann habe ich mir die “funktionale” Version angesehen:
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))
}
und versucht herauszufinden, wie man das “forall” auf prägnante Weise loswird. Ich bin kläglich gescheitert und kam mit
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))
}
wodurch sich meine schlaue 5-Zeilen-Lösung auf 12 Zeilen aufgebläht hat. Diese Version läuft jedoch ein 0,71 Sekunden, die gleiche Geschwindigkeit wie die ursprüngliche Java-Version und 56-mal schneller als die obige Version mit “forall” (40,2 s)! (siehe EDIT unten, warum dies schneller als Java ist)
Natürlich war mein nächster Schritt, das obige wieder in Java zu übersetzen, aber Java kann damit nicht umgehen und wirft einen StackOverflowError mit n um die 22000-Marke.
Ich habe mich dann ein bisschen am Kopf gekratzt und das “while” durch etwas mehr Schwanzrekursion ersetzt, was ein paar Zeilen spart, genauso schnell läuft, aber seien wir ehrlich, ist verwirrender zu lesen:
Scalas Schwanzrekursion gewinnt also den Tag, aber ich bin überrascht, dass etwas so Einfaches wie eine „for“-Schleife (und die „forall“-Methode) im Wesentlichen kaputt ist und durch unelegante und ausführliche „whiles“ oder Schwanzrekursion ersetzt werden muss . Viele der Gründe, warum ich Scala ausprobiere, sind die knappe Syntax, aber es nützt nichts, wenn mein Code 100-mal langsamer läuft!
BEARBEITEN: (gelöscht)
BEARBEITEN VON BEARBEITEN: Frühere Abweichungen zwischen den Laufzeiten von 2,5 s und 0,7 s waren ausschließlich darauf zurückzuführen, ob die 32-Bit- oder 64-Bit-JVMs verwendet wurden. Scala von der Befehlszeile verwendet, was auch immer von JAVA_HOME festgelegt wird, während Java 64-Bit verwendet, falls verfügbar, unabhängig davon. IDEs haben ihre eigenen Einstellungen. Einige Messungen hier: Scala-Ausführungszeiten in Eclipse
die isDivis-Methode kann geschrieben werden als: def isDivis(x: Int, i: Int): Boolean = if (i > 20) true else if (x % i != 0) false else isDivis(x, i+1). Beachten Sie, dass if-else in Scala ein Ausdruck ist, der immer einen Wert zurückgibt. Das Return-Schlüsselwort ist hier nicht erforderlich.
– Kiritsuku
28. Mai 2011 um 10:08 Uhr
Ihre letzte Version (P005_V3) kann kürzer, aussagekräftiger und IMHO klarer gemacht werden, indem man schreibt: def isDivis(x: Int, i: Int): Boolean = (i > 20) || (x % i == 0) && isDivis(x, i+1)
– Blaisorlade
29. März 2012 um 13:25 Uhr
@Blaisorblade Nein. Dies würde die Tail-Rekursivität unterbrechen, die erforderlich ist, um in eine While-Schleife im Bytecode zu übersetzen, was wiederum die Ausführung schnell macht.
– gzm0
28. Mai 2013 um 18:45 Uhr
Ich verstehe Ihren Punkt, aber mein Beispiel ist immer noch schwanzrekursiv, da && und || Verwenden Sie die Kurzschlussauswertung, wie durch die Verwendung von @tailrec bestätigt: gist.github.com/Blaisorblade/5672562
– Blaisorlade
29. Mai 2013 um 18:30 Uhr
Die Antwort zum Verständnis ist richtig, aber es ist nicht die ganze Geschichte. Sie sollten beachten, dass die Verwendung von return in isEvenlyDivisible ist nicht frei. Die Verwendung von Return innerhalb der forzwingt den Scala-Compiler, eine nicht-lokale Rückgabe zu generieren (dh außerhalb seiner Funktion zurückzugeben).
Dies geschieht durch die Verwendung einer Ausnahme zum Verlassen der Schleife. Dasselbe passiert, wenn Sie Ihre eigenen Kontrollabstraktionen erstellen, zum Beispiel:
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()
Dadurch wird “Hi” nur einmal gedruckt.
Notiere dass der return in foo Ausgänge foo (was man erwarten würde). Da der Ausdruck in Klammern ein Funktionsliteral ist, können Sie dies in der Signatur von sehen loop Dies zwingt den Compiler, eine nicht lokale Rückgabe zu generieren, d. h. die return zwingt Sie zum Ausstieg foonicht nur die body.
In Java (dh der JVM) besteht die einzige Möglichkeit, ein solches Verhalten zu implementieren, darin, eine Ausnahme auszulösen.
Zurück gehen zu isEvenlyDivisible:
def isEvenlyDivisible(a:Int, b:Int):Boolean = {
for (i <- 2 to b)
if (a % i != 0) return false
return true
}
Das if (a % i != 0) return false ist ein Funktionsliteral mit einer Rückgabe, sodass die Laufzeit jedes Mal, wenn die Rückgabe getroffen wird, eine Ausnahme auslösen und abfangen muss, was ziemlich viel GC-Overhead verursacht.
Einige Möglichkeiten, um die forall Methode, die ich entdeckt habe:
Den Bereich vorab instanziieren, damit wir nicht jedes Mal einen neuen Bereich erstellen: 9,0 Sek
val r = (1 to 20)
def isDivis(x:Int) = r forall {x % _ == 0}
Konvertieren in eine Liste statt in einen Bereich: 4,8 Sek
val rl = (1 to 20).toList
def isDivis(x:Int) = rl forall {x % _ == 0}
Ich habe ein paar andere Sammlungen ausprobiert, aber List war am schnellsten (obwohl immer noch 7x langsamer, als wenn wir die Range- und Higher-Order-Funktion insgesamt vermeiden würden).
Obwohl ich neu bei Scala bin, würde ich vermuten, dass der Compiler leicht eine schnelle und signifikante Leistungssteigerung implementieren könnte, indem er Range-Literale in Methoden (wie oben) einfach automatisch durch Range-Konstanten im äußersten Bereich ersetzt. Oder besser, internieren Sie sie wie Strings-Literale in Java.
Fußnote: Arrays waren ungefähr das gleiche wie Range, aber interessanterweise ein neues Pimping forall -Methode (siehe unten) führte zu einer um 24 % schnelleren Ausführung auf 64-Bit und zu einer um 8 % schnelleren Ausführung auf 32-Bit. Als ich die Berechnungsgröße reduzierte, indem ich die Anzahl der Faktoren von 20 auf 15 reduzierte, verschwand der Unterschied, also ist es vielleicht ein Garbage-Collection-Effekt. Was auch immer die Ursache ist, es ist wichtig, wenn es über längere Zeiträume unter Volllast betrieben wird.
Ein ähnlicher Zuhälter für List führte auch zu einer um etwa 10 % besseren Leistung.
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)
Ara Wartanian
Ich wollte nur für Leute, die wegen solcher Probleme das Vertrauen in Scala verlieren, anmerken, dass diese Art von Problemen bei der Leistung von fast allen funktionalen Sprachen auftreten. Wenn Sie einen Fold in Haskell optimieren, müssen Sie ihn oft als rekursive Tail-Call-optimierte Schleife neu schreiben, da Sie sonst mit Leistungs- und Speicherproblemen zu kämpfen haben.
Ich weiß, es ist bedauerlich, dass FPs noch nicht so weit optimiert sind, dass wir über solche Dinge nicht nachdenken müssen, aber das ist überhaupt kein Scala-spezifisches Problem.
Peter Mortensen
Scala-spezifische Probleme wurden bereits diskutiert, aber das Hauptproblem besteht darin, dass die Verwendung eines Brute-Force-Algorithmus nicht sehr cool ist. Bedenken Sie Folgendes (viel schneller als der ursprüngliche 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
}))
Die Fragen vergleichen die Leistung einer bestimmten Logik in verschiedenen Sprachen. Ob der Algorithmus für das Problem optimal ist, ist unerheblich.
– smartnut007
11. Dezember 2014 um 19:35 Uhr
13303200cookie-checkWie optimiert man For-Comprehensions und Schleifen in Scala?yes
This website is using cookies to improve the user-friendliness. You agree by using the website further.
kompilieren oder interpretieren Sie mit der Scala-Shell?
– ahmet alp balkan
26. Mai 2011 um 23:23 Uhr
Es gibt einen besseren Weg, dies zu tun, als die Trial-Division (Hinweis).
– Hammar
26. Mai 2011 um 23:41 Uhr
du zeigst nicht, wie du das timst. Haben Sie versucht, nur das Timing der
run
Methode?– Aaron Novstrup
26. Mai 2011 um 23:57 Uhr
@hammar – ja, habe es einfach mit Stift und Papier gemacht: Schreiben Sie die Primfaktoren für jede Zahl auf, beginnend mit hoch, und streichen Sie dann die Faktoren durch, die Sie bereits für höhere Zahlen haben, sodass Sie mit (5 * 2 * 2) abschließen. *(19)*(3*3)*(17)*(2*2)*()*(7)*(13)*()*(11) = 232792560
– Luigi Plinge
27. Mai 2011 um 1:43 Uhr
+1 Dies ist die interessanteste Frage, die ich seit Wochen auf SO gesehen habe (das hat auch die beste Antwort, die ich seit langem gesehen habe).
– Mia Clarke
28. Mai 2011 um 7:46 Uhr