Spark: Perché Python supera significativamente Scala nel mio caso d'uso?


16

Per confrontare le prestazioni di Spark durante l'utilizzo di Python e Scala ho creato lo stesso lavoro in entrambe le lingue e confrontato il runtime. Mi aspettavo che entrambi i lavori impiegassero all'incirca la stessa quantità di tempo, ma il lavoro in Python impiegava solo 27min, mentre il lavoro in Scala impiegava 37min(quasi il 40% in più!). Ho implementato lo stesso lavoro anche in Java e ci è voluto 37minutesanche. Come è possibile che Python sia molto più veloce?

Esempio verificabile minimo:

Lavoro Python:

# Configuration
conf = pyspark.SparkConf()
conf.set("spark.hadoop.fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider")
conf.set("spark.executor.instances", "4")
conf.set("spark.executor.cores", "8")
sc = pyspark.SparkContext(conf=conf)

# 960 Files from a public dataset in 2 batches
input_files = "s3a://commoncrawl/crawl-data/CC-MAIN-2019-35/segments/1566027312025.20/warc/CC-MAIN-20190817203056-20190817225056-00[0-5]*"
input_files2 = "s3a://commoncrawl/crawl-data/CC-MAIN-2019-35/segments/1566027312128.3/warc/CC-MAIN-20190817102624-20190817124624-00[0-3]*"

# Count occurances of a certain string
logData = sc.textFile(input_files)
logData2 = sc.textFile(input_files2)
a = logData.filter(lambda value: value.startswith('WARC-Type: response')).count()
b = logData2.filter(lambda value: value.startswith('WARC-Type: response')).count()

print(a, b)

Lavoro alla Scala:

// Configuration
config.set("spark.executor.instances", "4")
config.set("spark.executor.cores", "8")
val sc = new SparkContext(config)
sc.setLogLevel("WARN")
sc.hadoopConfiguration.set("fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider")

// 960 Files from a public dataset in 2 batches 
val input_files = "s3a://commoncrawl/crawl-data/CC-MAIN-2019-35/segments/1566027312025.20/warc/CC-MAIN-20190817203056-20190817225056-00[0-5]*"
val input_files2 = "s3a://commoncrawl/crawl-data/CC-MAIN-2019-35/segments/1566027312128.3/warc/CC-MAIN-20190817102624-20190817124624-00[0-3]*"

// Count occurances of a certain string
val logData1 = sc.textFile(input_files)
val logData2 = sc.textFile(input_files2)
val num1 = logData1.filter(line => line.startsWith("WARC-Type: response")).count()
val num2 = logData2.filter(line => line.startsWith("WARC-Type: response")).count()

println(s"Lines with a: $num1, Lines with b: $num2")

Solo guardando il codice, sembrano essere identici. Ho guardato i DAG e non hanno fornito alcun approfondimento (o almeno mi manca il know-how per trovare una spiegazione basata su di essi).

Gradirei davvero qualsiasi suggerimento.


I commenti non sono per una discussione estesa; questa conversazione è stata spostata in chat .
Samuel Liew

1
Avrei iniziato l'analisi, prima di chiedere qualsiasi cosa, temporizzando i blocchi e le istruzioni corrispondenti per vedere se c'era un posto particolare in cui la versione di Python è più veloce. Quindi potresti essere stato in grado di affinare la domanda su "perché questa affermazione di Python è più veloce".
Terry Jan Reedy,

Risposte:


11

Il tuo presupposto di base, che Scala o Java dovrebbe essere più veloce per questo compito specifico, è semplicemente errato. Puoi verificarlo facilmente con applicazioni locali minime. Scala uno:

import scala.io.Source
import java.time.{Duration, Instant}

object App {
  def main(args: Array[String]) {
    val Array(filename, string) = args

    val start = Instant.now()

    Source
      .fromFile(filename)
      .getLines
      .filter(line => line.startsWith(string))
      .length

    val stop = Instant.now()
    val duration = Duration.between(start, stop).toMillis
    println(s"${start},${stop},${duration}")
  }
}

Python one

import datetime
import sys

if __name__ == "__main__":
    _, filename, string = sys.argv
    start = datetime.datetime.now()
    with open(filename) as fr:
        # Not idiomatic or the most efficient but that's what
        # PySpark will use
        sum(1 for _ in filter(lambda line: line.startswith(string), fr))

    end = datetime.datetime.now()
    duration = round((end - start).total_seconds() * 1000)
    print(f"{start},{end},{duration}")

Risultati (300 ripetizioni ciascuno, Python 3.7.6, Scala 2.11.12), Posts.xmldal dump di dati hermeneutics.stackexchange.com con mix di pattern corrispondenti e non corrispondenti:

grafici a scatole di durartion in millis per i programmi precedenti

  • Python 273.50 (258.84, 288.16)
  • Scala 634.13 (533.81, 734.45)

Come vedi Python non è solo sistematicamente più veloce, ma è anche più coerente (diffusione inferiore).

Il messaggio da asporto è - non credere a FUD non comprovato - le lingue possono essere più veloci o più lente su compiti specifici o con ambienti specifici (ad esempio qui Scala può essere colpita dall'avvio di JVM e / o GC e / o JIT), ma se si afferma come "XYZ è X4 più veloce" o "XYZ è lento rispetto a ZYX (..) Approssimativamente, 10 volte più lento" di solito significa che qualcuno ha scritto un codice molto cattivo per testare le cose.

Modifica :

Per rispondere ad alcune preoccupazioni sollevate nei commenti:

  • Nel codice OP i dati vengono trasmessi principalmente in una direzione (JVM -> Python) e non è necessaria una vera serializzazione (questo percorso specifico passa semplicemente restringendo così com'è e decodifica su UTF-8 sull'altro lato). È economico quanto si arriva alla "serializzazione".
  • Ciò che viene restituito è solo un singolo numero intero per partizione, quindi in quella direzione l'impatto è trascurabile.
  • La comunicazione avviene tramite socket locali (tutte le comunicazioni sul lavoratore oltre la connessione iniziale e l'autenticazione vengono eseguite utilizzando il descrittore di file restituito local_connect_and_authe non è altro che un file associato al socket ). Ancora una volta, il più economico possibile quando si tratta di comunicazione tra processi.
  • Considerando la differenza nelle prestazioni grezze mostrate sopra (molto più alte di quelle che vedi nel tuo programma), c'è un sacco di margine per le spese generali elencate sopra.
  • Questo caso è completamente diverso dai casi in cui oggetti semplici o complessi devono essere passati ae dall'interprete Python in una forma accessibile a entrambe le parti come dump compatibili con i sottaceti (gli esempi più importanti includono UDF vecchio stile, alcune parti di vecchi stile MLLib).

Modifica 2 :

Poiché jasper-m era preoccupato per i costi di avvio qui, si può facilmente dimostrare che Python ha ancora un vantaggio significativo rispetto a Scala anche se la dimensione dell'input è significativamente aumentata.

Ecco i risultati per 2003360 righe / 5.6G (lo stesso input, appena duplicato più volte, 30 ripetizioni), che supera di gran lunga qualsiasi cosa ci si possa aspettare in una singola attività Spark.

inserisci qui la descrizione dell'immagine

  • Python 22809.57 (21466.26, 24152.87)
  • Scala 27315.28 (24367.24, 30263.31)

Si prega di notare intervalli di confidenza non sovrapposti.

Modifica 3 :

Per rispondere a un altro commento di Jasper-M:

La maggior parte dell'elaborazione sta ancora avvenendo all'interno di una JVM nel caso Spark.

Questo è semplicemente errato in questo caso particolare:

  • Il lavoro in questione è il lavoro di mappatura con una singola riduzione globale mediante RDD PySpark.
  • PySpark RDD (a differenza di quanto diciamo DataFrame) implementa in modo nativo le funzionalità al lordo di Python, con input, output e comunicazione tra nodi.
  • Poiché si tratta di un lavoro a singolo stadio e l'output finale è abbastanza piccolo da essere ignorato, la principale responsabilità di JVM (se si doveva eseguire il nitpick, questo è implementato principalmente in Java e non in Scala) è invocare il formato di input di Hadoop e inviare i dati attraverso il socket file su Python.
  • La parte letta è identica per l'API JVM e Python, quindi può essere considerata come overhead costante. Inoltre non si qualifica come la maggior parte dell'elaborazione , anche per un lavoro così semplice come questo.

3
ottimo approccio al problema. Grazie per aver condiviso questo
Alexandros Biratsis il

1
@egordoe Alexandros ha affermato che "non esiste alcuna UDF invocata qui", non che "Python non è invocata", il che fa la differenza. L'overhead di serializzazione è importante quando vengono scambiati dati tra sistemi (ovvero quando si desidera passare i dati a un UDF e viceversa).
user10938362

1
@egordoe Si confondono chiaramente due cose: sovraccarico della serializzazione, che è un problema in cui gli oggetti non banali vengono passati avanti e indietro. E sovraccarico di comunicazione. C'è poco o nessun sovraccarico di serializzazione qui, perché basta passare e decodificare i bytestring, e ciò accade principalmente in direzione, poiché indietro si ottiene un intero intero per partizione. La comunicazione è un po 'preoccupante, ma il passaggio di dati attraverso socket locali è efficiente in quanto arriva davvero quando si tratta di comunicazione tra processi. Se questo non è chiaro, ti consiglio di leggere la fonte: non è difficile e sarà illuminante.
user10938362

1
Inoltre, i metodi di serializzazione non sono uguali. Come dimostra il caso Spark, i buoni metodi di serializzazione possono ridurre i costi al livello in cui non è più preoccupante (vedi Pandas UDF con Arrow) e quando ciò accade, altri fattori possono dominare (vedi ad esempio i confronti delle prestazioni tra le funzioni della finestra Scala e i loro equivalenti con Pandas UDF - Python vince con un margine molto più alto lì, rispetto a questa domanda).
user10938362

1
E il tuo punto è @ Jasper-M? Le singole attività Spark sono in genere abbastanza piccole da avere un carico di lavoro paragonabile a questo. Non prendermi nel modo sbagliato, ma se hai qualche controesempio reale che invalida questa o l'intera domanda, ti preghiamo di pubblicarla. Ho già notato che le azioni secondarie contribuiscono in qualche misura a questo valore, ma non dominano il costo. Siamo tutti ingegneri (di qualche tipo) qui - parliamo di numeri e codice, non di credenze, vero?
user10938362

4

Il lavoro Scala richiede più tempo perché presenta un'errata configurazione e, pertanto, i lavori Python e Scala sono stati forniti con risorse disuguali.

Ci sono due errori nel codice:

val sc = new SparkContext(config) // LINE #1
sc.setLogLevel("WARN")
sc.hadoopConfiguration.set("fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider")
sc.hadoopConfiguration.set("spark.executor.instances", "4") // LINE #4
sc.hadoopConfiguration.set("spark.executor.cores", "8") // LINE #5
  1. LINEA 1. Una volta eseguita la riga, la configurazione delle risorse del processo Spark è già stabilita e corretta. Da questo momento in poi, non c'è modo di aggiustare nulla. Né il numero di esecutori né il numero di core per esecutore.
  2. LINEA 4-5. sc.hadoopConfigurationè un posto sbagliato per impostare qualsiasi configurazione Spark. Dovrebbe essere impostato confignell'istanza a cui si passa new SparkContext(config).

[AGGIUNTO] Tenendo presente quanto sopra, proporrei di modificare il codice del lavoro Scala in

config.set("spark.executor.instances", "4")
config.set("spark.executor.cores", "8")
val sc = new SparkContext(config) // LINE #1
sc.setLogLevel("WARN")
sc.hadoopConfiguration.set("fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider")

e riprovare di nuovo. Scommetto che la versione Scala sarà X volte più veloce ora.


Ho verificato che entrambi i lavori eseguono 32 attività in parallelo, quindi non credo che questo sia il colpevole?
Maestromusica,

grazie per la modifica, proverò a provarlo subito
maestromusica,

ciao @maestromusica deve essere qualcosa nella configurazione delle risorse perché, intrinsecamente, Python potrebbe non superare la Scala in questo particolare caso d'uso. Un altro motivo potrebbe essere costituito da alcuni fattori casuali non correlati, ad esempio il carico del cluster in un determinato momento e simili. A proposito, che modalità usi? autonomo, locale, filato?
egordoe,

Sì, ho verificato che questa risposta non è corretta. Il runtime è lo stesso. Ho anche stampato la configurazione in entrambi i casi ed è identica.
Maestromusica,

1
Penso che potresti avere ragione. Ho fatto questa domanda per indagare su tutte le altre possibilità come errori nel codice o forse che ho frainteso qualcosa. Grazie per il tuo contributo.
Maestromusica,
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.