Come ottimizzare comprensioni e cicli in Scala?


131

Quindi Scala dovrebbe essere veloce come Java. Sto rivisitando alcuni problemi di Project Euler a Scala che inizialmente ho affrontato in Java. In particolare Problema 5: "Qual è il numero positivo più piccolo che è uniformemente divisibile per tutti i numeri da 1 a 20?"

Ecco la mia soluzione Java, che richiede 0,7 secondi per essere completata sulla mia macchina:

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();
    }
}

Ecco la mia "traduzione diretta" in Scala, che richiede 103 secondi (147 volte di più!)

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
    }
}

Finalmente ecco il mio tentativo di programmazione funzionale, che richiede 39 secondi (55 volte in più)

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))
}

Utilizzo di Scala 2.9.0.1 su Windows 7 a 64 bit. Come posso migliorare le prestazioni? Sto facendo qualcosa di sbagliato? O Java è molto più veloce?


2
compili o interpreti usando scala shell?
AhmetB - Google,

C'è un modo migliore per farlo che usare la divisione di prova ( Suggerimento ).
Hammar,

2
non mostri come stai facendo questo. Hai provato a programmare il runmetodo?
Aaron Novstrup

2
@hammar - sì, l'ho fatto solo con il metodo carta e penna: annota i fattori primi per ogni numero che inizia con il numero più alto, quindi elimina i fattori che hai già per i numeri più alti, quindi finisci con (5 * 2 * 2) * (19) * (3 * 3) * (17) * (2 * 2) * () * (7) * (13) * () * (11) = 232792560
Luigi Plinge

2
+1 Questa è la domanda più interessante che ho visto da settimane su SO (che ha anche la migliore risposta che ho visto da un po 'di tempo).
Mia Clarke,

Risposte:


111

Il problema in questo caso particolare è che si ritorna dall'espressione for. Ciò a sua volta viene tradotto in un lancio di NonLocalReturnException, che viene catturato dal metodo che racchiude. L'ottimizzatore può eliminare la foreach ma non può ancora eliminare il lancio / cattura. E il lancio / cattura è costoso. Ma poiché tali ritorni nidificati sono rari nei programmi Scala, l'ottimizzatore non ha ancora affrontato questo caso. È in corso un lavoro per migliorare l'ottimizzatore che, si spera, risolverà presto questo problema.


9
Abbastanza pesante che un ritorno diventa un'eccezione. Sono sicuro che sia documentato da qualche parte, ma ha l'odore di incognita magia nascosta. È davvero l'unico modo?
skrebbel,

10
Se il ritorno avviene dall'interno di una chiusura, sembra essere la migliore opzione disponibile. I ritorni da chiusure esterne sono ovviamente tradotti direttamente per restituire istruzioni nel bytecode.
Martin Odersky,

1
Sono sicuro che sto trascurando qualcosa, ma perché non compilare invece il ritorno dall'interno di una chiusura per impostare un flag booleano e un valore di ritorno racchiusi, e verificarlo dopo il ritorno della chiamata di chiusura?
Luke Hutteman,

9
Perché il suo algoritmo funzionale è ancora 55 volte più lento? Non sembra che dovrebbe soffrire di una performance così orribile
Elia il

4
Ora, nel 2014, l'ho provato di nuovo e per me le prestazioni sono le seguenti: java -> 0.3s; scala -> 3.6s; scala ottimizzata -> 3.5s; scala funzionale -> 4s; Sembra molto meglio di 3 anni fa, ma ... Comunque la differenza è troppo grande. Possiamo aspettarci ulteriori miglioramenti delle prestazioni? In altre parole, Martin, c'è qualcosa, in teoria, lasciato per possibili ottimizzazioni?
sasha.sochka,

80

Molto probabilmente il problema è l'uso di una forcomprensione del metodo isEvenlyDivisible. La sostituzione forcon un whileciclo equivalente dovrebbe eliminare la differenza di prestazioni con Java.

A differenza dei forcicli di Java , le forcomprensioni di Scala sono in realtà zucchero sintattico per metodi di ordine superiore; in questo caso, stai chiamando il foreachmetodo su un Rangeoggetto. Quello di Scala forè molto generale, ma a volte porta a performance dolorose.

Potresti provare la -optimizebandiera in Scala versione 2.9. Le prestazioni osservate possono dipendere dalla particolare JVM in uso e dall'ottimizzatore JIT con tempo di "riscaldamento" sufficiente per identificare e ottimizzare gli hot-spot.

Recenti discussioni sulla mailing list indicano che il team Scala sta lavorando per migliorare le forprestazioni in casi semplici:

Ecco il problema nel bug tracker: https://issues.scala-lang.org/browse/SI-4633

Aggiornamento 5/28 :

  • Come soluzione a breve termine, il plug-in ScalaCL (alfa) trasformerà semplici loop Scala in equivalenti di whileloop.
  • Come potenziale soluzione a più lungo termine, i team dell'EPFL e di Stanford stanno collaborando a un progetto che consente la compilazione run-time di Scala "virtuale" per prestazioni molto elevate. Ad esempio, più loop funzionali idiomatici possono essere fusi in fase di esecuzione nel bytecode JVM ottimale o su un altro target come una GPU. Il sistema è estensibile, consentendo DSL e trasformazioni definite dall'utente. Consulta le pubblicazioni e gli appunti del corso di Stanford . Il codice preliminare è disponibile su Github, con una versione prevista nei prossimi mesi.

6
Fantastico, ho sostituito il per comprensione con un ciclo while e funziona esattamente alla stessa velocità (+/- <1%) della versione Java. Grazie ... ho quasi perso la fiducia in Scala per un minuto! Ora devo solo lavorare su un buon algoritmo funzionale ... :)
Luigi Plinge,

24
Vale la pena notare che le funzioni ricorsive della coda sono veloci quanto i loop while (poiché entrambi sono convertiti in bytecode molto simile o identico).
Rex Kerr,

7
Anche questo mi ha preso una volta. Ho dovuto tradurre un algoritmo dall'uso delle funzioni di raccolta in cicli annidati (livello 6!) A causa dell'incredibile rallentamento. Questo è qualcosa che deve essere fortemente preso di mira, imho; a che serve un bel stile di programmazione se non riesco a usarlo quando ho bisogno di prestazioni decenti (nota: non velocissime)?
Raffaello,

7
Quando è foradatto allora?
OscarRyz,

@OscarRyz - a for in scala si comporta come for (:) in java, per la maggior parte.
Mike Axiak,

31

Come follow-up, ho provato il flag -optimize e ha ridotto il tempo di esecuzione da 103 a 76 secondi, ma è comunque 107 volte più lento di Java o di un ciclo while.

Quindi stavo guardando la versione "funzionale":

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))
}

e cercando di capire come sbarazzarsi del "forall" in modo conciso. Ho fallito miseramente e mi è venuta in mente

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))
}

per cui la mia astuta soluzione a 5 righe è passata a 12 righe. Tuttavia, questa versione funziona in 0,71 secondi , la stessa velocità della versione Java originale e 56 volte più veloce della versione precedente usando "forall" (40.2 s)! (vedi EDIT di seguito per perché questo è più veloce di Java)

Ovviamente il mio prossimo passo è stato di riportare quanto sopra in Java, ma Java non può gestirlo e genera StackOverflowError con n attorno al segno 22000.

Ho poi grattato la testa per un po 'e ho sostituito il "while" con un po' più di ricorsione della coda, che salva un paio di linee, corre altrettanto veloce, ma ammettiamolo, è più confuso leggere:

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))
}

Quindi la ricorsione della coda di Scala vince la giornata, ma sono sorpreso che qualcosa di semplice come un ciclo "for" (e il metodo "forall") sia essenzialmente rotto e debba essere sostituito da "whiles" ineleganti e prolissi, o ricorsione della coda . Molte delle ragioni per cui sto provando Scala sono a causa della sintassi concisa, ma non va bene se il mio codice verrà eseguito 100 volte più lentamente!

EDIT : (cancellato)

EDIT OF EDIT : le precedenti discrepanze tra i tempi di esecuzione di 2,5 e 0,7 erano interamente dovute all'uso delle JVM a 32 o 64 bit. Scala dalla riga di comando utilizza tutto ciò che è impostato da JAVA_HOME, mentre Java utilizza 64 bit se disponibile indipendentemente. Gli IDE hanno le proprie impostazioni. Alcune misure qui: tempi di esecuzione Scala in Eclipse


1
il metodo isDivis può essere scritta come: def isDivis(x: Int, i: Int): Boolean = if (i > 20) true else if (x % i != 0) false else isDivis(x, i+1). Si noti che in Scala if-else è un'espressione che restituisce sempre un valore. Non c'è bisogno della parola chiave return qui.
Kiritsuku,

3
L'ultima versione ( P005_V3) può essere resa più breve, più dichiarativa e più chiara per IMHO scrivendo:def isDivis(x: Int, i: Int): Boolean = (i > 20) || (x % i == 0) && isDivis(x, i+1)
Blaisorblade

@Blaisorblade No. Ciò interromperebbe la ricorsività della coda, che è richiesta per tradurre in un ciclo while in bytecode, che a sua volta velocizza l'esecuzione.
gzm0,

4
Vedo il tuo punto, ma il mio esempio è ancora ricorsivo dalla coda poiché && e || utilizzare la valutazione del corto circuito, come confermato utilizzando @tailrec: gist.github.com/Blaisorblade/5672562
Blaisorblade

8

La risposta per la comprensione è giusta, ma non è l'intera storia. Si noti che l'uso di returnin isEvenlyDivisiblenon è gratuito. L'uso di return all'interno di for, forza il compilatore scala a generare un ritorno non locale (cioè per tornare al di fuori della sua funzione).

Questo viene fatto tramite l'uso di un'eccezione per uscire dal loop. Lo stesso accade se si creano le proprie astrazioni di controllo, ad esempio:

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()

Questo stampa "Ciao" solo una volta.

Nota che returnin fooesce foo(che è quello che ti aspetteresti). Poiché l'espressione tra parentesi è una funzione letterale, che puoi vedere nella firma di loopquesto forza il compilatore a generare un ritorno non locale, cioè le returnforze che esci foo, non solo il body.

In Java (ovvero la JVM) l'unico modo per implementare tale comportamento è quello di lanciare un'eccezione.

Tornando a isEvenlyDivisible:

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

Il if (a % i != 0) return falseè un letterale funzione che ha un ritorno, in modo ogni volta che il ritorno è colpito, il runtime deve lanciare e catturare un'eccezione, che provoca un po 'di GC in testa.


6

Alcuni modi per accelerare il forallmetodo che ho scoperto:

L'originale: 41,3 s

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

Pre-istanza dell'intervallo, quindi non creiamo un nuovo intervallo ogni volta: 9,0 s

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

Conversione in un elenco anziché in un intervallo: 4,8 s

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

Ho provato alcune altre raccolte ma List è stato il più veloce (anche se ancora 7 volte più lento di se evitassimo il Range e la funzione di ordine superiore).

Mentre sono nuovo in Scala, immagino che il compilatore potrebbe facilmente implementare un guadagno di prestazioni rapido e significativo semplicemente sostituendo automaticamente i valori letterali Range nei metodi (come sopra) con le costanti Range nell'ambito più esterno. O meglio, internali come letterali di stringhe in Java.


nota a piè di pagina : le matrici erano quasi uguali a quelle di Range, ma è interessante forallnotare che il potenziamento di un nuovo metodo (mostrato di seguito) ha comportato un'esecuzione più rapida del 24% su 64 bit e dell'8% più veloce su 32 bit. Quando ho ridotto le dimensioni del calcolo riducendo il numero di fattori da 20 a 15, la differenza è scomparsa, quindi forse è un effetto di garbage collection. Qualunque sia la causa, è significativo quando si opera a pieno carico per lunghi periodi.

Un magnaccia simile per List ha portato anche a prestazioni migliori di circa il 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)  

3

Volevo solo commentare per le persone che potrebbero perdere la fiducia in Scala su problemi come questo che questi tipi di problemi emergono nell'esecuzione di quasi tutti i linguaggi funzionali. Se stai ottimizzando una piega in Haskell, dovrai spesso riscriverla come un ciclo ricorsivo ottimizzato per le chiamate in coda, altrimenti avrai problemi di prestazioni e memoria con cui affrontare.

So che è sfortunato che i PQ non siano ancora ottimizzati al punto da non dover pensare a cose del genere, ma questo non è affatto un problema particolare per Scala.


2

Sono già stati discussi problemi specifici di Scala, ma il problema principale è che l'uso di un algoritmo a forza bruta non è molto interessante. Considera questo (molto più veloce del codice Java originale):

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
}))

Le domande confronta le prestazioni di una logica specifica tra le lingue. Non importa se l'algoritmo sia ottimale per il problema.
smartnut007,

1

Prova il one-liner fornito nella soluzione Scala per Project Euler

Il tempo dato è almeno più veloce del tuo, anche se lontano dal ciclo while .. :)


È abbastanza simile alla mia versione funzionale. Puoi scrivere il mio come def r(n:Int):Int = if ((1 to 20) forall {n % _ == 0}) n else r (n+2); r(2), che è di 4 caratteri più corto di quello di Pavel. :) Comunque non pretendo che il mio codice sia buono - quando ho pubblicato questa domanda avevo programmato un totale di circa 30 righe di Scala.
Luigi Plinge,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.