Ga naar inhoud

Automatische vertaling

Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Moderne dataverwerkingsengines vergeleken: Polars, DataFusion, Daft, Ray Data, Pandas en Spark

Jarenlang deed Pandas het in-memory tabulaire werk en deed Apache Spark het gedistribueerde werk. Die scheiding werkte prima zolang de data gestructureerd was.

Moderne workloads zijn niet altijd meer gestructureerd. Image-, audio- en videopijplijnen bestaan nu naast tabulaire pijplijnen, en zodra GPU-inference in beeld komt, zijn de garbage collection van de JVM en de GIL van Python geen irritaties meer maar de bottleneck. Dat is een groot deel van de reden waarom een nieuwe generatie engines gebouwd op Rust en Apache Arrow zo snel is opgekomen.

Ik heb het afgelopen jaar het grootste deel van mijn single-node-pijplijnen naar Polars verplaatst, en ik wilde zien hoe het zich in de praktijk verhoudt tot de rest van deze nieuwe stack. Daarom heb ik Polars, DataFusion, Daft, Ray Data en Spark gebenchmarkt op echte datasets — NYC-taxiritten voor de tabulaire kant, Food-101-afbeeldingen voor de multimodale kant.

Alle code staat in de repository engine-comparison-demo. Clone die en draai de benchmarks op je eigen hardware — jouw cijfers zullen anders zijn dan de mijne, en dat is precies het punt.

TL;DR: In marketingvriendelijke benchmarks halen Rust-native engines snelheidswinsten tot 94x ten opzichte van Pandas op één node. In echte workloads zie je iets bescheidener maar wel consistent — Polars en DataFusion zijn allebei sterke standaardkeuzes. Voor multimodale pijplijnen draaien gepijplijnde engines zoals Ray Data en Daft tot 18x sneller dan Spark omdat ze GPU's daadwerkelijk bezig houden. Er is geen algemene winnaar. De juiste keuze hangt af van je data, je schaal en of GPU's onderdeel van de lus zijn.


Typen dataverwerkingsworkloads

Engines specialiseren zich, dus de eerste vraag is welk type workload je daadwerkelijk hebt. De grootste scheiding is gestructureerd versus multimodaal.

Twee werelden van data: gestructureerd vs. multimodaal

Gestructureerde / tabulaire data. Filteren, aggregeren, joinen. CPU-gebonden, meestal in memory. Veel van dit werk draait op gedistribueerde clusters die überhaupt niet gedistribueerd hoefden te zijn — ongeveer 90% van de queries verwerkt minder dan 1 TB data, en dat past tegenwoordig prima op één machine.

Multimodale AI. Afbeeldingen, audio, video. GPU-gebonden aan de inference-kant, maar de CPU-preprocessing is wat je opbreekt. Deep-learningmodellen verwerken data sneller dan CPU's die kunnen decoderen, dus op een standaard g6.xlarge (4 CPU's per GPU) zakt de GPU-utilisatie onder de 20%. De hele pijplijn wordt uiteindelijk begrensd door hoe snel je de GPU kunt voeden.

De nieuwe engines richten zich op beide. Rust geeft ze snelheid, Arrow geeft ze zero-copy datadeling tussen processen, en streaming execution laat ze datasets verwerken die groter zijn dan RAM.


Deel 1: Single-node-verwerking

De Pandas-bottleneck is geen bug. Het is het ontwerp. Pandas laadt bestanden eager in RAM, kopieert tussenresultaten bij bijna elke bewerking, en blijft op één thread vanwege de GIL. In de PDS-H-benchmarks op scale factor 10 doet Pandas er ongeveer 365 seconden over om een standaard querysuite af te ronden. De streaming engine van Polars rondt hetzelfde werk af in 3,89 seconden — ongeveer 94x sneller.

Eager vs. lazy execution

Polars: verwerking van tabulaire data

Polars heeft Pandas vervangen als standaard voor serieuze lokale ETL in veel teams waarmee ik heb gesproken. Het is geschreven in Rust en gebruikt lazy execution: in plaats van elke regel direct uit te voeren, bouwt het een queryplan op, optimaliseert dat (predicate pushdown, projection pruning, column pruning) en voert het daarna uit over alle CPU-cores in streaming batches.

De kloof wordt groter naarmate de data groeit. Bij scale factor 100 (~100 GB) rondde de streaming engine van Polars af in 23,94 seconden tegenover 152,27 seconden voor de eigen in-memory-engine — ongeveer 6x sneller, op data die groter is dan RAM.

Uit engine_comparison_examples.ipynb:

import polars as pl

# scan_parquet reads only the schema — no data loaded yet
q = (
    pl.scan_parquet("yellow_tripdata_2024-01.parquet")
    .filter(
        (pl.col("trip_distance") > 5.0)
        & (pl.col("total_amount") > 30.0)
    )
    .group_by("payment_type")
    .agg(
        pl.col("total_amount").mean().alias("avg_fare"),
        pl.col("trip_distance").mean().alias("avg_distance"),
        pl.len().alias("trip_count"),
    )
)

# The entire plan is optimized and executed here, in parallel
result = q.collect()

Het verschil met Pandas is niet de API. Het is de architectuur. Polars bouwt eerst het plan en optimaliseert de hele pijplijn voordat het data aanraakt. De filter wordt doorgeduwd naar de Parquet-reader, zodat complete row groups worden overgeslagen. Alleen de kolommen waarnaar in de query wordt verwezen, worden van disk gelezen. Pandas kan dat allemaal niet — elke regel wordt uitgevoerd zodra die is geparseerd en tussenkopieën ontstaan overal.

Apache DataFusion: uitbreidbare query-engine

Waar Polars een library is die je direct gebruikt, is DataFusion de engine waarop je andere engines bouwt. Het drijft InfluxDB 3.0, GreptimeDB en Apple's Comet Spark accelerator aan.

In november 2024 werd DataFusion de snelste single-node-engine voor het queryen van Parquet-bestanden in ClickBench, vóór DuckDB, chDB en ClickHouse op dezelfde hardware. Het Embucket-team draaide later TPC-H op scale factor 1000 (~1 TB) op een enkele node erbovenop. Dat is een nuttig getal om in gedachten te houden wanneer iemand zegt dat er een cluster nodig is.

Uit engine_comparison_examples.ipynb:

from datafusion import SessionContext

ctx = SessionContext()
ctx.register_parquet("taxi", "yellow_tripdata_2024-01.parquet")

# SQL executed directly against Parquet — no intermediate copies
df = ctx.sql("""
    SELECT payment_type,
           COUNT(*)           AS trip_count,
           AVG(trip_distance) AS avg_distance,
           AVG(total_amount)  AS avg_fare
    FROM taxi
    WHERE trip_distance > 5.0 AND total_amount > 30.0
    GROUP BY payment_type
    ORDER BY trip_count DESC
""")
result = df.to_pandas()

De kracht van DataFusion is de modulariteit. De extension API's dekken custom catalogs, table providers, optimizer rules en execution plans, en daarom duikt het steeds op wanneer iemand een custom dataplatform bouwt of een query-engine in een eigen product embedt.

Daft: multimodale dataverwerking

Daft is degene die multimodale data echt serieus neemt. Afbeeldingen, audio, video, embeddings — het zijn echte types in de DataFrame, geen ondoorzichtige bytes. Pandas behandelt een afbeelding als een byte-array, Spark serializeert die via de JVM, maar Daft heeft native Rust-expressies voor het decoderen van afbeeldingen, het ophalen van URL's en het produceren van tensors zonder de Python-loop ertussen.

Uit engine_comparison_examples.ipynb:

import daft

df = daft.read_parquet("yellow_tripdata_2024-01.parquet")

result = (
    df.where(
        (daft.col("trip_distance") > 5.0)
        & (daft.col("total_amount") > 30.0)
    )
    .groupby("payment_type")
    .agg(
        daft.col("total_amount").mean().alias("avg_fare"),
        daft.col("trip_distance").mean().alias("avg_distance"),
        daft.col("trip_distance").count().alias("trip_count"),
    )
    .collect()
)

De API voelt vertrouwd als je Pandas kent, maar de engine is dezelfde Rust + Arrow-stack die je ook in Polars en DataFusion vindt. Waar Daft zich uitbetaalt, is wanneer de "rijen" in je DataFrame afbeeldingen, PDF's of tensors zijn.

Benchmark: single-node-prestaties

Ik heb alle vier engines gedraaid, plus native Rust via Polars-rs, op ~41M NYC yellow taxi trips voor het volledige jaar 2024. Volledige resultaten staan in de demo-repo:

Benchmarkresultaten: single-node-prestaties

Bij deze omvang (~660 MB Parquet) is elke moderne engine snel. Het headline-getal van 94x voor Polars versus Pandas komt uit PDS-H, een synthetische analytische benchmark — op mijn workload van 41M rijen zag ik een echte, consistente verbetering ten opzichte van Pandas, maar nergens in de buurt van 94x. Het interessantere resultaat is dat Polars, DataFusion, Daft en pure Rust via Polars-rs bij deze workload allemaal ongeveer in hetzelfde bereik uitkomen. DataFusion zit net iets voor de rest qua totale tijd. Polars is het prettigst om mee te schrijven en een sterke allrounder.


Deel 2: Omgaan met multimodale data

Tabulaire ETL verkleint data meestal: filteren, aggregeren, minder wegschrijven dan je inleest. Multimodale AI-pijplijnen doen het tegenovergestelde. Een enkel documentpad kan uitwaaieren in tientallen tekstchunks en embeddingvectoren.

Spark behandelt afbeeldingen en audio als binaire blobs. Die in Python aanraken betekent een JVM-naar-Python-hop via Py4J, verwerking met Pillow of OpenCV, en daarna weer terug serializeren. Die round-trip domineert de wall-clock-tijd in veel echte workloads.

Gepijplijnde uitvoering en GPU-utilisatie

Spark paralleliseert partities over cores, maar de stage-based (bulk synchronous) execution is de valkuil. Binnen één taak draait elke stap (downloaden, decoderen, classificeren) sequentieel op één core zonder asynchrone overlap. En elke taak in een stage moet klaar zijn voordat de volgende stage begint. GPU's zitten stil terwijl CPU's preprocessen; CPU's zitten stil terwijl GPU's inference draaien. Elke stage materialiseert zijn output voordat de volgende begint, wat boven op alles ook nog geheugenpressure veroorzaakt.

Model voor gepijplijnde uitvoering

Pipelined execution is het alternatief. In plaats van stages achter elkaar uit te voeren, laat de engine I/O, CPU-werk en GPU-inference overlappen, zodat op elk moment alleen de traagste component wacht.

Benchmark: beeldverwerking

Ik heb beeldverwerking gebenchmarkt op 500 echte foto's uit de Food-101-dataset. Resultaten uit de demo-repo:

Benchmarkresultaten: multimodale prestaties

Polars en DataFusion verschijnen hier niet omdat ze geen native image-operaties hebben — beeldwerk zou terugvallen op sequentiële Python en de vergelijking zou niet eerlijk zijn. De cijfers die tellen: de Rust-native image-ops van Daft zijn 3,6x sneller dan Pandas + Pillow, en pure Rust is 4,2x sneller dan Pandas over de hele pijplijn.


Deel 3: Gedistribueerde verwerking

Zodra één machine niet genoeg meer is, moet je kiezen hoe je het werk spreidt. Hier beginnen de architecturale verschillen tussen engines echt relevant te worden.

Apache Spark

Spark is nog steeds waar ik naar zou grijpen voor tabulaire ETL op petabyte-schaal met rommelige shuffles. Fault tolerance via RDD lineage werkt, het ecosysteem is groot (Databricks, EMR), en joins van meerdere TB's zijn beproefd terrein. AI-workloads zijn waar het uit elkaar valt. Taken worden ingepland op JVM-executors die niets van GPU's weten, dus GPU's wachten werkloos op CPU-preprocessing.

Uit distributed_spark.ipynb:

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder \
    .appName("TaxiETL") \
    .config("spark.sql.adaptive.enabled", "true") \
    .getOrCreate()

orders = spark.read.parquet("s3a://lake/taxi/*.parquet")
zones = spark.read.csv("s3a://lake/taxi_zones.csv", header=True)

# Spark excels at this: joining massive tables with a shuffle
result = (
    orders
    .filter(F.col("trip_distance") > 5.0)
    .join(zones, orders.PULocationID == zones.LocationID, "inner")
    .groupBy("Borough")
    .agg(
        F.sum("total_amount").alias("total_revenue"),
        F.avg("total_amount").alias("avg_fare"),
    )
    .orderBy(F.desc("total_revenue"))
)

Ray Data: GPU-utilisatie

Ray Data is ontworpen rond AI-workloads. In plaats van de stage barriers van Spark gebruikt het een streaming model dat GPU's gevoed houdt. De feature die zijn bestaansrecht bewijst, is mixed resource scheduling: je kunt declareren dat de ene actor "1 GPU, 4 CPU's" wil en een andere alleen CPU's, en Ray regelt de rest.

Amazon meldde een besparing van meer dan $120 miljoen per jaar door specifieke workloads van Spark naar Ray te verplaatsen. Hun PoC-cijfers waren 91% betere kostenefficiëntie en 13x meer data per uur; in productie kwam het uit op 82% betere kostenefficiëntie per GiB S3-input.

Uit distributed_ray.ipynb:

import ray

ray.init()

ds = ray.data.read_images("s3://my-bucket/food101/")

class ImageClassifier:
    def __init__(self):
        import torch
        from torchvision.models import resnet18, ResNet18_Weights
        self.model = resnet18(weights=ResNet18_Weights.DEFAULT).cuda()
        self.model.eval()
        self.preprocess = ResNet18_Weights.DEFAULT.transforms()

    def __call__(self, batch):
        import torch
        tensors = torch.stack([
            self.preprocess(img) for img in batch["image"]
        ]).cuda()
        with torch.no_grad():
            preds = self.model(tensors)
        return {
            "prediction": preds.argmax(dim=1).cpu().numpy(),
            "confidence": preds.max(dim=1).values.cpu().numpy(),
        }

# ActorPoolStrategy creates persistent GPU workers
predictions = ds.map_batches(
    ImageClassifier,
    compute=ray.data.ActorPoolStrategy(size=4),
    num_gpus=1,
    batch_size=64,
)
predictions.write_parquet("s3://output/predictions/")

Eén detail dat handig is om te kennen: als je meer CPU's per GPU toevoegt, schaalt Ray Data door terwijl andere engines plafonneren. In de benchmarks van Anyscale leverde een overgang van een 4:1 naar 32:1 CPU-naar-GPU-ratio een 3x speedup op voor image inference, omdat de CPU-feeders eindelijk de GPU konden bijbenen.

Daft (gedistribueerd)

Daft schaalt uit via zijn Flotilla-engine: één Swordfish-worker per node, met Flotilla erbovenop voor scheduling over het hele cluster. Swordfish verzorgt lokale Rust-uitvoering en pijplijnt I/O met compute via small-batch streaming, zodat elke node bezig blijft zonder op de volgende stage te wachten.

Over vier multimodale workloads heen draaide Daft met Flotilla 2–18x sneller dan Spark. Bij de zwaarste (video object detection met YOLO11n) deed Spark er meer dan 3,5 uur over en was Daft in minder dan 12 minuten klaar — 18x, vooral omdat Spark die tijd kwijt is aan serialisatie en stage barriers.

Eén kanttekening: Anyscale, het bedrijf achter Ray, publiceerde eigen benchmarks waarin Ray Data het gat dichtloopt of omdraait op high-CPU-instances met handmatige tuning. Op dit moment springen die twee elkaar per kwartaal voorbij.

Uit distributed_daft.ipynb:

import daft
from daft import col

# Daft's Flotilla engine distributes work across the cluster
df = daft.read_parquet("s3://data-lake/pdf_metadata/*.parquet")

# Download PDFs — Daft parallelizes downloads in Rust
df = df.with_column("pdf_bytes", col("pdf_url").url.download())

# Define a GPU UDF for embedding generation
@daft.udf(return_dtype=daft.DataType.list(daft.DataType.float32()))
class TextEmbedder:
    def __init__(self):
        from sentence_transformers import SentenceTransformer
        self.model = SentenceTransformer("all-MiniLM-L6-v2", device="cuda")

    def __call__(self, text_col):
        texts = text_col.to_pylist()
        embeddings = self.model.encode(texts, batch_size=32)
        return [emb.tolist() for emb in embeddings]

# Daft schedules CPU downloads and GPU embeddings simultaneously
df = df.with_column("embedding", TextEmbedder(col("text")))
df.write_parquet("s3://output/embeddings/")

Gedistribueerd head-to-head

Feature Spark Ray Data Daft (Flotilla)
Execution Model Taak-per-core, partitiegebaseerd Streaming tasks en actors Swordfish-per-node, streaming batches
Strengths Enorme SQL/ETL, fault tolerance Heterogene compute, GPU-saturatie Multimodale pipelining, begrensd geheugen
GPU Utilization Slecht (stage-based, geen CPU/GPU-overlap) Uitstekend (expliciete resource-toewijzing) Uitstekend (asynchrone I/O-compute-pipelining)
Tuning Burden Hoog (executor memory, cores, partitions) Gemiddeld (batch sizes, object store) Laag (engine beheert resources)
Best For Petabyte tabulaire ETL Training/inference met gemengde CPU+GPU Multimodaal dataloaden voor PyTorch

Deel 4: De rol van Rust en Arrow

Onder de concurrentie is de convergentie het interessantere verhaal. Polars, Daft, DataFusion en de core van Ray delen allemaal dezelfde basis: Apache Arrow als in-memory-formaat en Rust voor concurrency.

Convergentie van Rust en Arrow

Dat is niet alleen architectonische netheid. De Arrow PyCapsule Interface (het protocol __arrow_c_stream__) laat je data tussen engines doorgeven zonder serialisatie: reken in DataFusion, geef het resultaat zonder kosten door aan Polars of PyArrow; doe multimodale preprocessing in Daft en voer Arrow-batches direct in een PyTorch DataLoader.

from datafusion import SessionContext
import polars as pl

# Compute in DataFusion (Rust-native execution)
ctx = SessionContext()
ctx.register_parquet("events", "events.parquet")
df = ctx.sql("""
    SELECT user_id, COUNT(*) as event_count
    FROM events WHERE event_type = 'purchase'
    GROUP BY user_id HAVING COUNT(*) > 5
""")

# Zero-copy conversion to Arrow, then to Polars
arrow_table = df.to_arrow_table()
df_polars = pl.from_arrow(arrow_table)

# Continue analysis in Polars
result = df_polars.with_columns(
    pl.col("event_count").rank().alias("rank")
).sort("rank")

Dus waarom Rust in plaats van de JVM?

  1. Geen garbage-collection-pauzes. Ownership en borrowing vervangen GC, wat betekent: geen stop-the-world-pauzes en geen onvoorspelbare latencyspikes. De OOM-fouten die op schaal opduiken in JVM-systemen verdwijnen grotendeels.

  2. Geen object-headerbelasting. De JVM voegt 12–16 bytes object header toe aan elk object. Over miljarden kleine objecten heen is dat op zichzelf al een geheugencapaciteitsbudget. Rust betaalt die prijs niet.

  3. Thread safety op compile-time. Het typesysteem van Rust wijst data races af voordat de binary überhaupt wordt gebouwd, dus multithreaded uitvoering brengt niet de gebruikelijke klasse subtiele concurrencybugs met zich mee.

Gecombineerd met de kolomgeoriënteerde geheugenlayout van Arrow verdwijnt de serialisatiebottleneck die in legacy JVM-stacks tot 30% van de CPU-cycli opslokt.


Deel 5: Meerdere engines adopteren

Het eerlijke antwoord is dat je niet één engine kiest. Je combineert ze.

Het co-existentiemodel: multi-engine-pijplijn

In productie ziet dat er meestal uit als een estafette. Ruwe data in een lake wordt opgeschoond en gejoint door Spark (of Dask als je team alleen Python gebruikt) naar Parquet- of Delta-tabellen. Die tabellen gaan vervolgens naar Ray Data of Daft voor het laatste stuk — GPU-inference, embeddinggeneratie, multimodale transformaties — waarvoor Spark nooit is ontworpen. Voor ad-hocanalyse en lokale ontwikkeling geven Polars en DuckDB je direct feedback zonder een cluster op te starten.

Wat deze compositie mogelijk maakt, zijn open formats: Parquet, Delta, Iceberg, Arrow. Elke engine in de stack leest en schrijft die native, dus de overdracht tussen stages is gewoon een bestandspad.

Samenvatting van use cases per engine

Engine-selectiematrix

Task Recommended Tool Trade-off
Ad-hoc analysis on a laptop Polars / DuckDB Snelheid > Schaal
Massive SQL ETL (petabyte) Apache Spark Betrouwbaarheid > Snelheid
Python-native scaling Dask Eenvoud > Optimalisatie
Training LLMs / computer vision Ray Data GPU-utilisatie > ETL-features
Multimodal data loading Daft Developer experience > Ecosysteem
Building custom query engines DataFusion Uitbreidbaarheid > Out-of-box-features

Diagonaal schalen

Lange tijd was de keuze: scale up (een grotere machine) of scale out (meer machines). Polars Cloud doet iets ertussenin, wat zij diagonal scaling noemen: horizontaal schalen tijdens het lezen uit cloud storage om I/O te maximaliseren, en daarna terugvallen naar één grote node zodra filters en aggregaties de data hebben verkleind, zodat de gedistribueerde shuffle helemaal wordt overgeslagen.

Het contra-intuïtieve deel: een grotere, duurdere instance kan goedkoper per job uitpakken omdat die de GPU-utilisatie hoog genoeg opvoert dat de totale uitvoeringstijd sneller daalt dan het uurtarief stijgt.


Probeer het zelf

De begeleidende demo-repository bevat alles wat je nodig hebt om deze benchmarks te reproduceren:

# Install dependencies
uv sync

# Run the tabular benchmark (~2.9M NYC taxi trips)
uv run python -m engine_comparison.benchmarks.tabular

# Run the multimodal benchmark (500 real food photos)
uv run python -m engine_comparison.benchmarks.multimodal

# Run native Rust benchmarks (Polars-rs + image crate)
cd rust_benchmark && cargo run --release && cd ..

De repo bevat ook notebooks voor side-by-side API-vergelijkingen en Docker Compose-opstellingen voor lokale gedistribueerde runs van Spark, Ray en Daft.


Belangrijkste conclusies

  1. Distribueer niet als het niet hoeft. Polars en DataFusion kunnen tot ~1 TB aan op één machine, met consistente snelheidswinsten ten opzichte van Pandas (en 94x op zware analytische benchmarks zoals PDS-H). Grijp pas naar een cluster als je echt tegen de limieten van een enkele node aanloopt, niet eerder.

  2. De limieten van Pandas zijn architecturaal. Eager execution, single-threading, tussenkopieën — dit zijn bewuste ontwerpkeuzes die bottlenecks worden zodra de data groot wordt. Lazy execution met predicate pushdown en column pruning is wat dat verandert.

  3. Pipelined execution verslaat stage barriers voor GPU-werk. De stages van Spark laten GPU's stilzitten terwijl CPU's preprocessen. Ray Data en Daft laten I/O, CPU- en GPU-werk overlappen zodat geen enkele resource zit te wachten op de volgende stage.

  4. Gebruik elke engine waarvoor die goed is. Spark voor petabyte-ETL, Polars en DuckDB voor ad-hocanalyse, Ray Data voor GPU-trainingspijplijnen, Daft voor multimodaal dataloaden, DataFusion voor custom query-engines.

  5. Rust + Arrow is de gedeelde basis. Het is geen toeval dat Polars, DataFusion, Daft en Ray hier allemaal op uitkomen — het haalt GC-pauzes, serialisatie-overhead tussen processen en een hele klasse concurrencybugs weg.

Weet je niet waar je moet beginnen: Polars op één machine, daarna Daft of Ray Data zodra GPU's in beeld komen. Spark alleen als je daadwerkelijk een petabyte hebt.


Referenties

Benchmarks en prestatiegegevens

Engines en frameworks

Architectuur en ecosysteem

Demo-repository

  • engine-comparison-demo - Begeleidende code voor dit artikel: benchmarks, notebooks en Docker Compose voor Spark/Ray/Daft

Datasets