MetricSign
NL|ENStart free →
Best Practices9 min·

Spark-prestaties: Scala versus Python: waar het er echt toe doet

Het verschil in uitvoeringstijd tussen PySpark en Scala wordt niet gemeten door de meeste benchmarks. De werkelijke kosten zitten hem in de serialisatiegrenzen, het uitvoeringsprocesmodel en waar u UDF's worden uitgevoerd.

Read this article in English →

De referentiewaarde die u hebt gelezen klopt waarschijnlijk niet.

De meeste blogposts die de Spark-prestaties voor Scala versus Python vergelijken, eindigen met één enkel getal: Scala is 10x sneller, 2x sneller of ongeveer gelijk. Dit getal klopt meestal voor de workload die de auteur heeft getest, maar is nutteloos voor uw workload.

Het geaccepteerde antwoord op Stack Overflow met 429 stemmen maakt het juiste onderscheid. Spark biedt drie uitvoeringspaden en de taalkeuze heeft op elk pad een andere invloed. De RDD API voert Python-code uit in een apart workerproces en betaalt serialisatiekosten per rij. De DataFrame API compileert naar een logisch Catalyst-plan en vervolgens naar een fysiek Tungsten-plan, waarbij geen van beide een regel Python- of Scala-code van u applicatie bevat. Spark SQL is in dit opzicht identiek aan DataFrame. UDF's (User Defined Functions) overbruggen alle drie de paden en introduceren de taalgrens opnieuw waar u ze ook gebruikt.

Uit de Stack Overflow-enquête onder ontwikkelaars van 2024 blijkt dat Python door 51% van de ontwikkelaars wordt gebruikt en Scala door 2,6%. De arbeidsmarkt weerspiegelt dat, de meeste datateams schrijven PySpark niet omdat ze het hebben gebenchmarkd, maar omdat hun engineers al Python beheersen. De interessante vraag is niet welke taal in abstracte zin sneller is. Het is eerder: ervan uitgaande dat u de job in PySpark hebt geschreven, waar kost de JVM-Python-grens u dan daadwerkelijk geld en welke van die kosten zijn het waard om op te lossen?

De rest van dit artikel beantwoordt die vraag. We bespreken de vier plekken waar de grens zich manifesteert. RDD-bewerkingen, Python UDF's, het executorprocesmodel en de driver-side collectie en hoe elk ervan eruitziet in een Databricks-joblogboek.

DataFrames overbruggen die kloof, totdat u een UDF schrijft.

Voer df.groupBy("customer_id").agg(sum("amount")) uit in PySpark en Scala. Bekijk het fysieke plan met df.explain(true) in beide talen. De uitvoer is identiek: een HashAggregate met een gedeeltelijke en een definitieve fase, een Exchange hashpartitioneringsshuffle en een Project. Catalyst maakt het niet uit in welke taal de DataFrame is opgebouwd. Beide aanroepen gaan één keer via de Py4j- of de Scala-API tijdens de planconstructie, waarna de JVM de volledige query uitvoert.

Dit is de reden waarom PySpark-taken met veel DataFrames binnen 5% van hun Scala-equivalenten presteren. Het Python-proces op de driver bouwt een logisch plan, stuurt dit naar de JVM en wacht. Executors voeren JVM-bytecode uit die is gegenereerd door Tungsten's codegeneratie voor alle fasen. Er is geen Python in het kritieke pad.

Op het moment dat u df.withColumn("score", my_python_udf(col("features"))) aanroept, verandert het beeld. Spark kan u functie niet rechtstreeks naar Catalyst pushen. Het voegt een BatchEvalPython operator toe aan het uitvoeringsplan, die elke rij serialiseert met pickle, deze via een socket naar een afgesplitste Python-worker stuurt, het resultaat deserialiseert en het opnieuw in de kolomgebaseerde batch invoegt. Bij een DataFrame met een miljard rijen is dit vele malen complexer dan de daadwerkelijke berekening. Een eenvoudige UDF die integers naar integers converteert, kan 40 minuten duren, terwijl de equivalente Scala UDF 90 seconden nodig heeft.

Pandas UDF's, geregistreerd met @pandas_udf, veranderen het protocol. Rijen worden gebundeld in Arrow-recordbatches, doorgaans 10.000 rijen per batch, beheerd door spark.sql.execution.arrow.maxRecordsPerBatch, en kolomgewijs verwerkt. Dezelfde workload daalt van 40 minuten naar ongeveer vier minuten. Nog steeds trager dan Scala, maar in dezelfde orde van grootte. Als u in 2026 PySpark UDF's schrijft zonder Arrow ingeschakeld, is dat het eerste wat u moet controleren: spark.sql.execution.arrow.pyspark.enabled = true.

Where Scala and PySpark actually diverge Scala Spark Aspect PySpark DataFrame API (df.groupBy.agg) Catalyst plan: HashAggregate, identical JVM bytecode Identical Catalyst plan, identical JVM bytecode Executor process model N task threads in one JVM N JVM threads + N forked Python worker processes Python/Scala UDFs Runs in JVM, no serialization overhead Pickle-and-pipe per row unless using Pandas UDF RDD ops (map, reduceByKey) Native JVM closures, no per-record overhead Per-record pickle round-trip, Python worker startup Driver-side work JVM-native, single process Py4J bridge between Python driver and JVM Memory on 16-core executor 1 JVM heap 1 JVM heap + up to 16 Python interpreter processes
Where Scala and PySpark actually diverge

Het uitvoeringsprocesmodel verhoogt de geheugendruk.

Scala Spark voert N jobthreads uit binnen één JVM per executor. PySpark voert N JVM-jobthreads uit en elk daarvan start een Python-workerproces zodra de Python-code voor het eerst moet worden geëvalueerd. Op een executor met 16 cores kunnen er 16 Python-processen plus de JVM actief zijn, elk met de interpreter, elke geïmporteerde module en een privékopie van elke broadcastvariabele.

Dit manifesteert zich op drie concrete manieren. Ten eerste gedragen broadcast joins zich slechter in PySpark. Een broadcastvariabele van 500 MB die eenmaal in Scala wordt gebruikt, verbruikt 500 MB × N in Python. Op een workernode met 64 GB en 8 executors van elk 16 cores, betekent dit 64 GB aan Python-kopieën voordat er daadwerkelijk data wordt verwerkt. Ten tweede krijg u te maken met de Linux OOM killer in plaats van een schone Spark OOM. Wanneer de kernel een Python-worker beëindigt, meldt de executor ExecutorLostFailure of PythonException zonder Java-stacktrace die de oorzaak aangeeft. Controleer dmesg of het kernel-logboek op de worker, u zult oom-killer-vermeldingen zien met de naam van de python3 PID.

Ten derde telt de geheugenadministratie van de container in YARN of Kubernetes het Python-geheugen mee ten opzichte van de totale limiet van de executor. De instelling die de headroom regelt, is spark.executor.memoryOverhead, die standaard is ingesteld op max(384MB, 0.10 × executorMemory). Voor PySpark-workloads met niet-triviale UDF's of broadcasts kunt u deze waarde verhogen naar 25-30%. Het symptoom van een foutieve instelling is dat de executor door YARN wordt beëindigd met exitcode 143 en de melding "Container killed by YARN for exceeding memory limits."

Dit alles gebeurt niet in Scala omdat er slechts één proces is. Het op threads gebaseerde model isoleert fouten niet zo duidelijk, een geheugenlek in één job kan de hele executor platleggen, maar de steady-state footprint is aanzienlijk lager.

Waar Scala nog steeds duidelijk in uitblinkt: RDD's en driver-side werk.

Als u codebase nog steeds gebruikmaakt van sc.textFile(...).map(...).reduceByKey(...), is de taalkeuze belangrijker dan waar dan ook in Spark. RDD-bewerkingen hebben geen Catalyst-optimizer. Elke map-functie is u eigen code, die voor elk record wordt uitgevoerd. In PySpark betekent dit dat u per record pickle-and-pipe uitvoert, zonder terugvaloptie naar Arrow, omdat RDD's al bestonden vóór de integratie met Arrow.

De oplossing is meestal niet om de code in Scala te herschrijven, maar om over te stappen op DataFrames. Een textFile + map + reduceByKey-pipeline heeft bijna altijd een equivalent, uitgedrukt als spark.read.text() met select, groupBy en agg, en dat equivalent draait op JVM-snelheid, ongeacht in welke taal het is geschreven. Het oorspronkelijke antwoord op Stack Overflow dateert uit 2015. De reden dat de waarschuwingen over RDD's nog steeds relevant zijn, is dat veel oude ETL-code nooit is gemigreerd.

Een ander voordeel van Scala is de aggregatie aan de driverzijde. Een veelvoorkomend patroon: verzamel een paar honderdduizend rijen naar de driver, verwerk ze na en schrijf een configuratiebestand. In Scala draait dit in de JVM die er al is. In PySpark worden de rijen via de py4j-interface naar het Python-proces gepickeld, waar u er vervolgens doorheen loopt. Voor 100.000 rijen van een breed schema kan dit 30 seconden duren, terwijl het in Scala bijna direct klaar is. De oplossing is meestal om de nabewerking terug te verplaatsen naar Spark, gebruik approxQuantile, percentile_approx of een windowfunctie in plaats van collect-and-iterate.

De cijfers uit het onderzoek bevestigen dit pragmatisch. Met een Scala-adoptie van 2,6% is het inhuren van een Scala-team om RDD-prestaties te verbeteren zelden de juiste keuze. Het inhuren van een PySpark-engineer die weet wanneer een Pandas UDF ingezet moet worden, is dat wel.

Hoe meet u dit precies op u cluster?

Stop met vertrouwen op externe benchmarks. De variabelen die er echt toe doen – de structuur van u data, u UDF's, u clustertopologie, u Spark-versie – zijn in geen enkele andere test meegenomen. Gebruik de Spark UI op Databricks om de werkelijke kosten te achterhalen.

Open het tabblad SQL/DataFrame en klik op een query. De planstructuur toont de timing op operatorniveau. De operators BatchEvalPython en ArrowEvalPython tonen een "tijd"-metriek – de werkelijke tijd die in de Python-omgeving is doorgebracht voor die fase. Als dit meer dan 20% van de totale querytijd is, vormen u UDF's de bottleneck. Als het minder dan 5% is, ligt het probleem niet bij de programmeertaal en zal herschrijven in Scala niet helpen.

Voor RDD-taken toont het tabblad Stages de verdeling van de jobduur. Vergelijk de mediane jobduur met het 95e percentiel. Een grote spreiding bij PySpark RDD-taken betekent vaak dat een paar executors Python-workers in en uit het geheugen laden. Combineer dit met het tabblad Executors, bekijk "Peak JVM Memory" ten opzichte van "Peak Other Memory". Overig geheugen wordt grotendeels door Python gebruikt. Als dit de memoryOverhead overschrijdt, dreigt uw applicatie te crashen.

Voor de kosten aan de driverzijde, bekijk de tijd tussen de voltooiingstijd van de laatste fase en de starttijd van de volgende fase. Lange periodes met een lage CPU-belasting aan de driverzijde betekenen dat py4j aan het marshallen is. Lange periodes met een hoge CPU-belasting aan de driverzijde betekenen dat uw Python-nabewerking de bottleneck vormt.

Dit is het soort regressie dat u wilt opsporen voordat belanghebbenden dat doen. Een PySpark-job die vorige week in 12 minuten draaide en vandaag in 47 minuten, heeft meestal niet van programmeertaal veranderd, er is een UDF bijgekomen, een Arrow is verdwenen of een broadcast-drempel is bereikt. Door de duur en fasegegevens over verschillende uitvoeringen bij te houden, wordt een onderzoek van een halve dag teruggebracht tot een verschil van vijf minuten.

Gerelateerde integraties

Gerelateerde artikelen

← Alle artikelenDelen op LinkedIn