“Parquet на 40% быстрее ORC” — бессмысленное утверждение без контекста. 40% быстрее на каком запросе? На каких данных? С какими настройками? На каком hardware? В Модуле 09 мы видели, как настройки компрессии меняют throughput в 3-5 раз. Формат бенчмарка без методологии — маркетинг, а не инженерия.
Этот урок — о том, как бенчмаркать, а не что быстрее. Результаты бенчмарков — в уроке 05 (Case Studies).
Пять измерений бенчмарка
Формат хранения — не одномерная шкала “быстро / медленно”. Каждый формат оптимизирован для своего набора операций. Бенчмарк без определения измерений — как тестировать автомобиль без указания, что именно тестируем: разгон, расход топлива или грузоподъёмность.
Пять измерений бенчмарка форматов
Write ThroughputСкорость записи данных: сколько MB/s или rows/s формат может записать. Зависит от: encoding overhead (dictionary building, RLE), compression ratio vs speed tradeoff, file metadata overhead. Для стриминга: latency записи одного micro-batch.
Scan SpeedСкорость полного или частичного scan'а: чтение N колонок из M доступных с/без предиката. Зависит от: columnar layout, decompression speed, vectorized execution support, I/O pattern (sequential vs random).
Predicate PushdownЭффективность пропуска данных по предикатам: сколько процентов данных формат НЕ читает при фильтрации. Зависит от: statistics (min/max per row group), bloom filters, zone maps, partition pruning. Измерять: bytes read / total bytes.
Random AccessСкорость чтения одной строки по ID/offset: latency p50/p99. Критично для ML (mini-batch sampling) и serving (point lookup). Parquet: ~1ms (page decode). Lance: ~10μs (direct offset). Row-stores: ~100μs.
Schema EvolutionСтоимость изменения схемы: добавление/удаление/переименование колонки. Измерять: время выполнения операции (metadata-only vs rewrite), влияние на downstream reads (backward compatibility), размер overhead.
WARNING
Не бенчмаркайте все 5 измерений одинаково. Из урока 01: определите архетип workload’а → выделите 2-3 deal-breaker’а → бенчмаркайте только их. OLAP → scan speed + predicate pushdown. Streaming → write throughput + scan speed. ML → random access + scan speed.
Типичные ошибки бенчмаркинга
Более 80% публичных бенчмарков форматов содержат хотя бы одну из этих ошибок. Распознавать их — навык, который важнее самих результатов.
Семь смертных грехов бенчмаркинга
1. Тёплый кэшДанные уже в page cache ОС после первого чтения. Повторные запуски читают из RAM (10 GB/s), не с диска (0.5 GB/s SSD, 0.1 GB/s HDD). Результат: формат 'в 20 раз быстрее' — на самом деле page cache в 20 раз быстрее диска. Исправление: drop caches между запусками (echo 3 > /proc/sys/vm/drop_caches) или cgroups с ограниченной памятью.
2. Default настройкиБенчмарк с дефолтными настройками обоих форматов. Но дефолты разные: Parquet default row group = 128MB, ORC default stripe = 64MB. Parquet default compression = Snappy, ORC default = ZLIB. Сравнивать дефолты = сравнивать настройки, не форматы. Исправление: тюнить оба формата под workload, потом сравнивать.
3. Синтетические данныеГенерация random() данных: равномерное распределение, нет корреляций, нет NULL'ов, нет skew. Реальные данные: Zipf distribution (90% значений — 10% уникальных), NULL'ы в 30% строк, temporal correlation. Dictionary encoding работает в 10x на реальных данных, в 1.1x на random. Исправление: бенчмаркать на prod-like данных или TPC-DS/TPC-H.
4. Один запросБенчмарк на одном SELECT: 'формат X на 15% быстрее на GROUP BY по date'. Но GROUP BY по date — один из сотен возможных запросов. Формат X может быть на 30% медленнее на JOIN или на scan без предиката. Исправление: набор из 10-20 запросов, покрывающих все паттерны.
5. Игнорирование writeБенчмаркать только read, игнорируя write throughput. Parquet с ZSTD level 19: отличная compression, отличный read speed — но write в 10x медленнее, чем ZSTD level 1. Для ETL-pipeline write throughput — bottleneck. Исправление: всегда бенчмаркать write + read вместе.
6. Один масштабБенчмарк на 1 GB данных. На 1 GB всё быстро — данные помещаются в кэш. На 1 TB — I/O pattern, memory pressure, parallelism становятся определяющими. Формат, быстрый на 1 GB, может быть медленным на 1 TB (плохой I/O pattern). Исправление: минимум 3 масштаба (1 GB, 100 GB, 1 TB).
7. Vendor biasБенчмарк от вендора формата на оптимизированном для него engine. Databricks бенчмаркает Delta на Photon (собственный engine) — результат нерелевантен для Spark OSS. Tabular (Iceberg) бенчмаркает на Trino с Iceberg-оптимизациями. Исправление: бенчмаркать на engine, который используете в production.
Измерение 1: Write Throughput
Write throughput — не просто “сколько MB/s”. Это стоимость трансформации данных из источника в формат хранения: encoding, compression, metadata generation, file finalization.
Write Pipeline: что влияет на throughput
Входные данные (Arrow / CSV / JSON)
Входные данные: Arrow RecordBatch, Pandas DataFrame, CSV-строки — зависит от источника. Формат входа влияет на стоимость записи: Arrow → Parquet = только encoding + compression. CSV → Parquet = parsing + type conversion + encoding + compression.
EncodingТрансформация значений в бинарное представление формата. Dictionary encoding: строим словарь уникальных значений + массив индексов. RLE: кодируем повторы. Delta encoding: храним разницы. Стоимость пропорциональна сложности encoding'а и cardinality данных.
CompressionСжатие закодированных данных. Snappy: быстрый, ratio ~2x. ZSTD: медленнее write, лучше ratio (~3-4x). LZ4: самый быстрый, ratio ~2x. Выбор compression = tradeoff: write speed vs read speed vs storage cost. См. Модуль 09.
MetadataГенерация statistics: min/max per column per row group, bloom filters, null count. Для Parquet: page index, column index, offset index. Для table formats: manifest/snapshot/commit. Overhead: 1-5% от данных, но bloom filters могут добавить 10%+.
File finalization + commit
Финализация файла: запись footer'а (Parquet), stripe footer + file footer (ORC), manifest + version log (Lance). Для table formats: atomic commit (Delta: JSON commit, Iceberg: snapshot, Hudi: instant). Finalization latency: ~10ms для файла, ~100ms для commit.
Как бенчмаркать write
# Паттерн бенчмарка записи (PyArrow + Parquet)import pyarrow as paimport pyarrow.parquet as pqimport time# Подготовить данные (Arrow table — один раз, вне замера)table = pa.table({"col1": range(10_000_000), "col2": ["val"] * 10_000_000})# Замер: только запись (не включаем генерацию данных)start = time.perf_counter()pq.write_table( table, "benchmark.parquet", compression="zstd", # Фиксировать compression compression_level=3, # Фиксировать level row_group_size=1_000_000, # Фиксировать row group size use_dictionary=True, # Фиксировать dictionary encoding)elapsed = time.perf_counter() - startmb = table.nbytes / 1024 / 1024print(f"{mb:.0f} MB written in {elapsed:.2f}s = {mb/elapsed:.0f} MB/s")
Write Throughput: что варьировать
CompressionВарьировать: Snappy, LZ4, ZSTD (level 1/3/9/19), None. Каждый уровень — отдельная точка на графике write speed vs compression ratio. ZSTD-1 часто — оптимум: почти скорость Snappy, ratio на 20-30% лучше.
Row Group SizeParquet row group: 64K, 256K, 1M, 10M строк. Маленькие row groups: больше metadata overhead, лучше predicate pushdown. Большие: меньше overhead, хуже pushdown. Write throughput обычно растёт с размером row group (меньше flushes).
ДанныеТестировать минимум на 3 типах данных: low cardinality strings (dictionary encoding эффективен), high cardinality integers (delta encoding), mixed (реалистичный набор колонок). Результаты отличаются в 2-5x в зависимости от данных.
Измерение 2: Scan Speed
Scan speed — основное измерение для OLAP workload’а. Включает: чтение данных с диска/storage, decompression, decoding, column projection, predicate evaluation.
Результат: Arrow RecordBatch с выбранными колонками и отфильтрованными строками. Метрика: rows/s или MB/s (decoded). Для сравнения форматов: фиксировать query, данные, hardware.
Набор запросов для scan бенчмарка
Один запрос — не бенчмарк. Минимальный набор для OLAP-workload:
Набор запросов для scan бенчмарка
Q1: Full ScanSELECT COUNT(*) FROM table — сканирование всех строк без projection. Измеряет: чистый I/O throughput + metadata scan. Baseline: сколько rows/s формат может прочитать без overhead'ов.
Q2: Column ProjectionSELECT col1, col2 FROM table — чтение 2 колонок из 50+. Измеряет: эффективность columnar layout, column skip. Parquet/ORC: почти линейная экономия (2/50 = 4% I/O). CSV/JSON: читают всё.
Q3: Predicate PushdownSELECT * FROM table WHERE high_selectivity_predicate — предикат, отсекающий 99% строк. Измеряет: эффективность statistics (min/max), bloom filters, zone maps. Bytes read / total bytes = selectivity metric.
Q4: AggregationSELECT category, SUM(amount) FROM table GROUP BY category — агрегация с группировкой. Измеряет: decode speed для dictionary-encoded строк + hash aggregation. Low cardinality category → dictionary encoding помогает.
Q5: JoinSELECT ... FROM fact JOIN dim ON key — join двух таблиц. Измеряет: scan speed обеих сторон + hash/sort join overhead. Для форматов: важен порядок данных (sorted = merge join, unsorted = hash join).
Q6: ComplexРеалистичный сложный запрос с subquery, CTE, window function. Измеряет: как формат влияет на реальный аналитический workload, не на синтетические примитивы. TPC-DS Query 1 или аналог.
Измерение 3: Predicate Pushdown
Predicate pushdown — ключевое преимущество колоночных форматов: способность не читать данные, которые не нужны. Метрика: pushdown ratio = 1 - (bytes_read / total_bytes).
Predicate Pushdown: уровни фильтрации
WHERE date >= ‘2025-01-01’
Запрос с предикатом: WHERE date >= '2025-01-01'. Формат может пропустить данные на нескольких уровнях: partition → file → row group → page. Каждый уровень сужает объём чтения.
L1: Partition PruningTable format уровень: пропуск целых партиций. Если таблица партиционирована по date — отсекаем все партиции до 2025. Стоимость: O(1) metadata read. Эффективность: до 99%+ skip для temporal queries.
L2: File/Row GroupФормат уровень: min/max statistics по колонке для каждого row group (Parquet) или stripe (ORC). Если min(date) > '2025-06-01' — пропускаем весь row group. Стоимость: чтение footer'а. Эффективность: 50-95% skip при sorted data.
L3: Page/BloomPage-level: Parquet page index (min/max per page, ~1MB). Bloom filter: проверка 'точно нет в row group' для equality predicates. Стоимость: чтение page index + bloom filter. Эффективность: ещё 10-50% skip поверх L2.
ИтогоСуммарный эффект: L1 + L2 + L3 может пропустить 99.9% данных для selective query на sorted/partitioned data. Но: если данные unsorted и без партиционирования — L1 не работает, L2 неэффективен (min/max перекрываются). Сортировка данных при записи — ключ к pushdown.
Как бенчмаркать predicate pushdown
# Измерение: bytes read vs total bytesimport pyarrow.parquet as pq# Чтение с предикатомmetadata = pq.read_metadata("benchmark.parquet")total_bytes = metadata.serialized_size# С фильтромtable = pq.read_table( "benchmark.parquet", filters=[("date", ">=", "2025-01-01")], columns=["amount", "category"])# Метрика: сколько row groups было пропущено?# PyArrow: read_metadata() → iterate row groups → check statisticsfor i in range(metadata.num_row_groups): rg = metadata.row_group(i) col = rg.column(date_col_idx) print(f"RG {i}: min={col.statistics.min}, max={col.statistics.max}")
Измерение 4: Random Access
Random access — критичное измерение для ML-workload’а. Метрика: p50/p99 latency для чтения одной строки по row ID.
Random Access: Parquet vs Lance
Parquet: чтение строки #42
Parquet random access: для чтения строки #42 нужно найти row group (binary search по row count) → загрузить column chunk → распаковать page → найти значение. Granularity = page (~1MB). Даже для одной строки — чтение ~1MB с диска.
Шаг 1Прочитать footer → найти row group, содержащий строку #42. Footer: ~10KB (кэшируется). Binary search: O(log G) где G = количество row groups.
Шаг 2Загрузить column chunk из найденного row group → найти page. Column chunk: ~1-10MB. Page: ~64KB. Даже если нужна одна строка — минимальное чтение = 1 page (64KB). На S3: 1 GET request = 100ms latency.
Шаг 3Декодировать весь page: decompress + decode dictionary/RLE/delta для всех строк в page, чтобы найти одну. CPU overhead: ~100μs на page. Итого: 64KB I/O + 100μs decode = ~1ms на локальном SSD, ~100ms на S3.
Lance: чтение строки #42
Lance random access: sliceable encodings позволяют прямой переход к offset строки без декодирования предыдущих. Granularity = 1 строка. Чтение: fragment lookup + direct offset read.
Шаг 1Row ID → fragment lookup. Row ID = fragment_id + offset. Manifest содержит row count per fragment → O(log F) lookup. Manifest: кэшируется (несколько KB).
Шаг 2Прямой переход к offset в data file. Sliceable encoding: фиксированный размер per value или index → offset вычисляется арифметически. Нет page-level granularity — читаем ровно нужные байты.
Шаг 3Чтение только нужных байтов: для fixed-width columns = несколько байт. Для variable-width: offset array + value bytes. Zero-copy в Arrow RecordBatch. Итого: ~10μs на SSD. 100x быстрее Parquet.
Бенчмарк random access
import lanceimport pyarrow.parquet as pqimport randomimport time# Подготовить индексы для random accessn_rows = 10_000_000sample_ids = random.sample(range(n_rows), 1000)# Lance: take() по row IDsds = lance.dataset("benchmark.lance")start = time.perf_counter()for rid in sample_ids: ds.take([rid])lance_elapsed = time.perf_counter() - start# Parquet: read_row_group + filter (приблизительный аналог)pf = pq.ParquetFile("benchmark.parquet")start = time.perf_counter()for rid in sample_ids: # Parquet не имеет нативного random access — это workaround rg_idx = rid // rows_per_rg pf.read_row_group(rg_idx).slice(rid % rows_per_rg, 1)parquet_elapsed = time.perf_counter() - startprint(f"Lance: {lance_elapsed/len(sample_ids)*1000:.2f} ms/row")print(f"Parquet: {parquet_elapsed/len(sample_ids)*1000:.2f} ms/row")
Измерение 5: Schema Evolution Cost
Schema Evolution: metadata-only vs rewrite
Metadata-Only
Metadata-only schema evolution: добавление/переименование/удаление колонки — только обновление metadata (footer, manifest, schema registry). Данные не перезаписываются. Стоимость: O(1) — секунды.
ФорматыIceberg: schema evolution через column IDs (Модуль 12, урок 04). Delta Lake: ALTER TABLE ADD COLUMN — metadata commit. Lance: manifest update. Avro: schema evolution через union types + Schema Registry. Все — O(1).
СтоимостьВремя: секунды. I/O: несколько KB metadata. Никакого влияния на существующие данные. Читатели со старой схемой продолжают работать (backward compatibility).
Full Rewrite
Full rewrite schema evolution: изменение типа колонки (int → long), изменение encoding (dictionary → plain). Требует перезаписи всех data files. Стоимость: O(N) — пропорциональна объёму данных.
ОперацииИзменение типа (int32 → int64), изменение порядка колонок (Parquet хранит по column index), удаление колонки в Parquet (нужен rewrite, т.к. column chunk встроен в row group). Table formats могут смягчить через lazy rewrite.
СтоимостьВремя: минуты-часы (пропорционально объёму данных). I/O: перезапись всех файлов. Для 10 TB данных: ~1-2 часа на мощном кластере. Для table formats: можно делать инкрементально (rewrite по партициям).
Воспроизводимый benchmark setup
Бенчмарк имеет ценность только если другая команда может получить те же результаты. Чеклист воспроизводимости:
Чеклист воспроизводимого бенчмарка
HardwareФиксировать и документировать: CPU (model, cores, frequency), RAM (size, speed), Storage (SSD model, IOPS, bandwidth), Network (для S3/cloud storage). Разные SSD отличаются в 2x по throughput. Cloud instances — указать тип (c5.4xlarge).
SoftwareВерсии всего стека: OS (kernel version), engine (Spark 3.5.1, DuckDB 0.10.2, etc.), format library (pyarrow 16.0, delta-rs 0.17), JVM params (для Spark: -Xmx, -Xms, GC). Одна версия Spark → другая может отличаться в 2x.
ДанныеОписание данных: schema (типы, количество колонок), объём (rows, bytes), distribution (cardinality, NULL ratio, skew), сортировка. Идеально: опубликовать dataset или скрипт генерации. TPC-DS/TPC-H — стандартные наборы.
Протокол запускаWarm-up runs: сколько (обычно 3). Measured runs: сколько (минимум 5). Cache policy: drop caches между запусками? Метрика: median (не mean — устойчив к выбросам). Confidence interval: p25-p75 или стандартное отклонение.
ПубликацияКод бенчмарка: в открытом репозитории. Raw results: CSV/JSON с каждым запуском (не только агрегаты). Конфиги: файлы настроек обоих форматов. README: шаги для воспроизведения от нуля.
TIP
Минимальный воспроизводимый бенчмарк: Docker-контейнер с фиксированным image, скрипт генерации данных, скрипт запуска бенчмарка, скрипт сбора результатов. docker run benchmark:v1 --format=parquet --scale=100gb — воспроизводимо на любой машине. В уроке 05 (Case Studies) мы применим эту методологию.
Framework выбора: что бенчмаркать для вашего workload
Workload → Какие измерения бенчмаркать
WorkloadАрхетип рабочей нагрузки из урока 01.
PrimaryОсновные измерения: бенчмаркать в первую очередь. Определяют выбор формата.
SecondaryДополнительные измерения: бенчмаркать если основные не выявили явного победителя.
OLAPАналитические scan'ы, BI, ad-hoc запросы.
Scan speed определяет user experience (время отклика dashboard). Predicate pushdown определяет I/O cost (cloud storage = платный I/O).