Learning Platform
Глоссарий Troubleshooting
Урок 18.02 · 35 мин
Продвинутый
BenchmarkingMethodologyWrite ThroughputScan SpeedPredicate PushdownRandom AccessSchema EvolutionReproducibility

Методология бенчмаркинга форматов

“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 pa
import pyarrow.parquet as pq
import 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() - start
mb = table.nbytes / 1024 / 1024
print(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.

Scan Speed: анатомия чтения

SELECT col1, col2 FROM table WHERE col3 > 100

Запрос: SELECT col1, col2 FROM table WHERE col3 > 100. Engine разбивает на: планирование (какие файлы/row groups читать) → I/O (загрузить данные) → decode (распаковать encoding) → filter (применить предикат) → project (выбрать колонки) → aggregate.
PlanningОпределить файлы, row groups, pages для чтения. Для table formats: прочитать metadata (manifest/snapshot) → partition pruning → file pruning. Latency: 10ms (локальный Parquet) — 1s (Iceberg с 10K+ файлов в S3).
I/OЧтение данных с storage. Локальный SSD: 3 GB/s sequential. S3: 0.1-0.5 GB/s per stream, 10-50 streams параллельно. Columnar format: читаем только нужные колонки = column projection. 3 из 100 колонок = 3% I/O.
DecodeРаспаковка encoding'а: decompress (ZSTD/Snappy/LZ4) → decode (dictionary → RLE → delta → plain values). CPU-bound операция. Vectorized decoding (batch of 1024 values) в 5-10x быстрее row-by-row. Arrow-native форматы (Lance): zero-copy — decode = memcpy.

Arrow RecordBatch → Engine

Результат: 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 bytes
import 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 statistics
for 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 lance
import pyarrow.parquet as pq
import random
import time

# Подготовить индексы для random access
n_rows = 10_000_000
sample_ids = random.sample(range(n_rows), 1000)

# Lance: take() по row IDs
ds = 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() - start

print(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 — стандартные наборы.
НастройкиВсе настройки форматов: compression (алгоритм + level), row group/stripe size, dictionary threshold, bloom filter columns, page size. Для table formats: partition scheme, sort order, compaction settings. Публикуйте конфиг файлы.
Протокол запуска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).
Write throughput (если ETL bottleneck). Schema evolution (если schema часто меняется).
StreamingCDC, event streams, continuous ingest.
Write throughput: мелкие батчи должны записываться быстро. Scan speed: downstream consumers читают incremental changes.
Schema evolution (producers меняют schema). Predicate pushdown (incremental queries фильтруют по timestamp).
ML/AITraining data, feature stores, RAG, vector search.
Random access: mini-batch sampling, point lookup features. Scan speed: full dataset scan при training.
Write throughput (embedding generation pipeline). Schema evolution (feature schema changes).
OperationalServing layer, point reads/writes.
Random access: point lookup latency p99. Write throughput: upsert latency.
Scan speed (для hybrid OLAP+OLTP). Predicate pushdown (для filtered serving).

Итоги

Бенчмарк формата без методологии — маркетинг. С методологией — инженерное решение:

  1. Определите измерения из workload’а (2-3 из пяти)
  2. Избегайте ошибок — тёплый кэш, дефолтные настройки, один запрос, один масштаб
  3. Тюньте оба формата под workload перед сравнением
  4. Документируйте всё — hardware, software, данные, настройки, протокол запуска
  5. Публикуйте код — бенчмарк без кода — это мнение

В следующем уроке — выбор table format (Delta Lake vs Iceberg vs Hudi vs Paimon) на основе конкретных критериев.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Бенчмарк показывает: Parquet на 40% быстрее ORC на scan запросе. Вы замечаете, что тесты запускались повторно без drop caches. Что на самом деле измерил бенчмарк?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5