Бенчмаркинг: DuckDB против Pandas и Spark
Мы построили пайплайн на DuckDB и убедились, что он считает витрину из 41 миллиона поездок даже на машине с памятью меньше датасета. Возникает законный вопрос: а почему именно DuckDB, а не Pandas (привычный инструмент аналитика) или Spark (стандарт для больших данных)? Ответ должен быть не «потому что так в курсе», а измеренным и объяснённым.
Этот урок — про честный бенчмарк. Возьмём ту же задачу расчёта витрины, прогоним её тремя инструментами, измерим время и память и, главное, объясним разницу через архитектуру. Цель — не «доказать, что DuckDB лучший», а понять, в каких задачах он выигрывает, в каких нет, и научиться мерить корректно.
Методика: как мерить честно
Бенчмарк легко сделать нечестным. Зафиксируем правила, без которых сравнение бессмысленно.
- Одна задача, одни данные, одно железо. Все три инструмента считают идентичную витрину (агрегация поездок по дням и зонам с join справочника) на одном и том же датасете на одной машине.
- Меряем и время, и пиковую память. Время без памяти обманчиво: инструмент может быть быстрым, но падать с OOM на большом объёме. Пиковая память — равноправная метрика.
- Несколько прогонов, учёт холодного старта. Первый прогон читает файлы с диска (cold), последующие — из кеша ОС (warm). Их нельзя смешивать; фиксируем оба или сравниваем одинаковые.
- Считаем сквозную задачу. Меряем не «скорость одного оператора», а весь путь: чтение сырых файлов, очистка, join, агрегация — то, что реально делает пайплайн.
Конкретные числа дальше — иллюстративные: они зависят от железа, версий, размера датасета и того, как написан код на каждом инструменте. Запоминать надо не цифры, а порядок величин и причины разницы. Причины архитектурны и стабильны; числа на вашей машине будут другими.
Три реализации одной задачи
Задача одна — посчитать mart_daily_zone. Посмотрим, как она выглядит на каждом инструменте.
DuckDB — это наш пайплайн из предыдущих уроков. Чтение Parquet напрямую, friendly SQL, larger-than-memory из коробки:
import duckdb, time
con = duckdb.connect()
con.execute("SET memory_limit = '8GB'")
t0 = time.perf_counter()
con.execute("""
CREATE TABLE mart AS
SELECT t.pickup_datetime::DATE AS trip_date, z.Borough AS borough,
count(*) AS trips, sum(t.total_amount) AS revenue
FROM read_parquet('raw/trips/*/*/trips.parquet') AS t
JOIN read_csv('raw/zones/zones.csv') AS z
ON t.pu_location_id = z.LocationID
GROUP BY ALL
""")
print(f"DuckDB: {time.perf_counter() - t0:.1f} с")
Pandas — привычный инструмент аналитика. Но здесь вылезает его архитектурное ограничение: Pandas грузит весь DataFrame в память:
import pandas as pd, glob, time
t0 = time.perf_counter()
# Pandas обязан прочитать все файлы целиком в RAM
trips = pd.concat(pd.read_parquet(f) for f in glob.glob('raw/trips/*/*/trips.parquet'))
zones = pd.read_csv('raw/zones/zones.csv')
merged = trips.merge(zones, left_on='pu_location_id', right_on='LocationID')
merged['trip_date'] = pd.to_datetime(merged['pickup_datetime']).dt.date
mart = merged.groupby(['trip_date', 'Borough']).agg(
trips=('total_amount', 'size'), revenue=('total_amount', 'sum')).reset_index()
print(f"Pandas: {time.perf_counter() - t0:.1f} с")
# На датасете больше RAM этот код падает с MemoryError на pd.concat
Spark — стандарт для распределённой обработки. Корректен на любом объёме, но несёт накладные расходы JVM и планировщика:
from pyspark.sql import SparkSession, functions as F
import time
spark = SparkSession.builder.master("local[*]").getOrCreate()
t0 = time.perf_counter()
trips = spark.read.parquet("raw/trips/*/*/trips.parquet")
zones = spark.read.csv("raw/zones/zones.csv", header=True, inferSchema=True)
mart = (trips.join(zones, trips.pu_location_id == zones.LocationID)
.groupBy(F.to_date("pickup_datetime").alias("trip_date"), "Borough")
.agg(F.count("*").alias("trips"), F.sum("total_amount").alias("revenue")))
mart.write.mode("overwrite").parquet("mart_spark")
print(f"Spark (local): {time.perf_counter() - t0:.1f} с")
Результаты и их чтение
Прогон на ноутбуке (8 ядер, 16 ГБ RAM) на капстоун-датасете 41 миллион поездок дал картину такого порядка:
| Инструмент | Время (warm) | Пиковая память | Поведение на датасете больше RAM |
|---|---|---|---|
| DuckDB | базовая (1x) | в пределах memory_limit | Считает, спиллит на диск |
| Pandas | в разы медленнее | весь датасет в RAM | Падает с MemoryError |
| Spark (local) | заметно медленнее DuckDB | управляется JVM | Считает, но с overhead планировщика |
Как это читать. На датасете, который помещается в память, DuckDB ощутимо быстрее Pandas и быстрее локального Spark. На датасете, который не помещается, Pandas просто не выполняет задачу — падает; DuckDB и Spark справляются, но DuckDB на одной машине обычно быстрее за счёт меньших накладных расходов.
Важна оговорка про Spark: сравнение честно для локального режима (local[*]) и масштаба «одна машина, десятки гигабайт». Spark проектировался под распределённый кластер из многих узлов и сотни терабайт — на таком масштабе и горизонтальном масштабировании это его территория, и сравнение с одномашинным DuckDB там некорректно. Капстоун-датасет — это как раз масштаб одной машины, и на нём DuckDB на месте.
Почему DuckDB выигрывает на этой задаче
Разница не магия — она следует из архитектуры. Соберём причины.
Против Pandas DuckDB выигрывает по четырём причинам. Pandas обрабатывает данные построчно-ориентированной логикой без векторизации уровня DuckDB; по умолчанию однопоточен, тогда как DuckDB использует morsel-driven параллелизм по всем ядрам; материализует каждый промежуточный DataFrame целиком, тогда как DuckDB гонит поток батчей; и держит всё в RAM, тогда как DuckDB спиллит на диск и потому вообще способен на эту задачу при датасете больше памяти. Плюс DuckDB читает Parquet с projection и filter pushdown, а Pandas через read_parquet обычно тянет файлы целиком.
Против локального Spark DuckDB выигрывает за счёт отсутствия накладных расходов. Spark несёт машинерию распределённого движка — JVM, сериализацию данных между стадиями, планировщик задач, — которая оправдана на кластере, но на одной машине становится чистым overhead. DuckDB — embedded-движок в одном процессе: ни JVM-разогрева, ни сетевой сериализации, ни кластерного планировщика.
И отдельное преимущество для аналитика — zero-copy с Pandas. DuckDB умеет запросить Pandas-DataFrame напрямую из локальной области видимости (replacement scan), без копирования данных. То есть DuckDB не вытесняет Pandas, а ускоряет его: тяжёлый join и агрегацию отдают DuckDB прямо над Pandas-объектом.
import duckdb, pandas as pd
trips_df = pd.read_parquet('raw/trips/year=2026/month=04/trips.parquet')
# DuckDB запрашивает Pandas-DataFrame по имени, без копирования данных
result = duckdb.sql("""
SELECT pu_location_id, count(*) AS trips, sum(total_amount) AS revenue
FROM trips_df GROUP BY pu_location_id ORDER BY revenue DESC
""").df()
Когда какой инструмент
Бенчмарк не объявляет один инструмент победителем навсегда — он размечает их области.
| Ситуация | Разумный выбор |
|---|---|
| Аналитика на одной машине, данные до десятков-сотен ГБ | DuckDB |
| Тяжёлый SQL поверх уже имеющегося Pandas-DataFrame | DuckDB через replacement scan |
| Мелкие данные, нужна экосистема Python (ML, графики) | Pandas |
| Истинно распределённая нагрузка, сотни ТБ, кластер | Spark |
| Сложный ML-пайплайн на Spark MLlib | Spark |
Главный вывод капстоуна: для «локального аналитического lakehouse» — данные на одной машине, объём в десятки гигабайт — DuckDB архитектурно на месте. Он быстрее Pandas и не падает там, где Pandas падает; он быстрее локального Spark и без его накладных расходов. Spark остаётся правильным выбором для настоящего распределённого масштаба, а Pandas — для мелких данных и богатой Python-экосистемы. Выбор инструмента — это совпадение его архитектуры с масштабом и характером задачи.
Попробуй сам
Понадобятся DuckDB 1.5.x, pandas, и опционально pyspark. Датасет — несколько помесячных Parquet-файлов.
Задания:
- Реализуйте одну агрегацию (join +
GROUP BY) на DuckDB и на Pandas. Замерьте время черезtime.perf_counter()и пиковую память (например, черезtracemallocили внешний монитор). Сделайте по 3 прогона, разделите cold и warm. - Постепенно увеличивайте датасет, добавляя файлы, пока Pandas не упрётся в
MemoryError. Зафиксируйте, на каком объёме это произошло, и убедитесь, что DuckDB ту же задачу проходит. - Если доступен
pyspark, прогоните ту же задачу в режимеlocal[*]и сравните с DuckDB. Объясните разницу через накладные расходы JVM и планировщика. - Сделайте
duckdb.sql("... FROM df ...")поверх Pandas-DataFrame (replacement scan) и сравните с эквивалентной чистой Pandas-агрегацией. Сформулируйте, почему DuckDB здесь дополняет Pandas, а не конкурирует с ним.
Polars: альтернатива Pandas с векторизацией без DuckDB