blob: c64b3dfbad748e478af269860dac26fa35d3aeb2 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Benchmark a realistic 15-leaf metrics predicate through manifest planning.
Run with:
uv run pytest tests/benchmark/test_metrics_evaluator_benchmark.py -v -s -m benchmark
"""
from __future__ import annotations
import statistics
import timeit
from collections.abc import Callable
from typing import Any
import pytest
import pyiceberg.table as table_module
from pyiceberg.conversions import to_bytes
from pyiceberg.expressions import And, BooleanExpression, EqualTo, GreaterThanOrEqual, LessThanOrEqual, Or
from pyiceberg.manifest import DataFile, FileFormat, ManifestContent, ManifestFile
from pyiceberg.schema import Schema
from pyiceberg.table import ManifestGroupPlanner, Table
from pyiceberg.typedef import Record
from pyiceberg.types import LongType, NestedField
from pyiceberg.utils.concurrent import ExecutorFactory
def _data_file(file_number: int) -> DataFile:
event_day = file_number % 11
region_id = file_number % 15
event_day_bytes = to_bytes(LongType(), event_day)
region_id_bytes = to_bytes(LongType(), region_id)
return DataFile.from_args(
file_path=f"s3://bucket/data-{file_number}.parquet",
file_format=FileFormat.PARQUET,
partition=Record(),
record_count=100,
file_size_in_bytes=1,
value_counts={1: 100, 2: 100},
null_value_counts={1: 0, 2: 0},
lower_bounds={1: event_day_bytes, 2: region_id_bytes},
upper_bounds={1: event_day_bytes, 2: region_id_bytes},
)
def _metrics_filter() -> BooleanExpression:
"""Select five day ranges, each scoped to a region."""
windows = ((0, 1, 1), (2, 3, 4), (4, 5, 7), (6, 7, 10), (8, 10, 13))
branches = [
And(
And(GreaterThanOrEqual("event_day", start_day), LessThanOrEqual("event_day", end_day)),
EqualTo("region_id", region_id),
)
for start_day, end_day, region_id in windows
]
combined = branches[0]
for branch in branches[1:]:
combined = Or(combined, branch)
return combined
def _manifest_file(manifest_number: int) -> ManifestFile:
return ManifestFile.from_args(
manifest_path=f"s3://bucket/manifest-{manifest_number}.avro",
manifest_length=1,
partition_spec_id=0,
content=ManifestContent.DATA,
sequence_number=1,
min_sequence_number=1,
added_snapshot_id=1,
)
class _SerialExecutor:
def map(self, function: Callable[..., Any], *iterables: Any, **_kwargs: Any) -> Any:
return map(function, *iterables)
@pytest.mark.benchmark
@pytest.mark.parametrize(
("files_per_manifest", "partition_matches"),
[(1_000, True), (1, True), (1_000, False), (1, False)],
ids=["dense", "fragmented", "all-pruned-dense", "all-pruned-fragmented"],
)
def test_metrics_evaluator_reuse(
table_v2: Table,
monkeypatch: pytest.MonkeyPatch,
files_per_manifest: int,
partition_matches: bool,
) -> None:
num_files = 1_000
schema = Schema(
NestedField(1, "event_day", LongType(), required=True),
NestedField(2, "region_id", LongType(), required=True),
*(NestedField(field_id, f"unused_{field_id}", LongType(), required=False) for field_id in range(3, 103)),
schema_id=table_v2.metadata.current_schema_id,
)
metadata = table_v2.metadata.model_copy(update={"schemas": [schema]})
planner = ManifestGroupPlanner(table_metadata=metadata, io=table_v2.io, row_filter=_metrics_filter())
data_files = [_data_file(file_number) for file_number in range(num_files)]
manifests = [_manifest_file(manifest_number) for manifest_number in range(0, num_files, files_per_manifest)]
files_by_manifest = {
manifest.manifest_path: data_files[start : start + files_per_manifest]
for manifest, start in zip(manifests, range(0, num_files, files_per_manifest), strict=True)
}
def open_manifest(
_io: Any,
manifest: ManifestFile,
partition_evaluator: Callable[[DataFile], bool],
metrics_evaluator: Callable[[DataFile], bool],
) -> list[DataFile]:
return [
data_file
for data_file in files_by_manifest[manifest.manifest_path]
if partition_evaluator(data_file) and metrics_evaluator(data_file)
]
monkeypatch.setattr(planner, "_build_manifest_evaluator", lambda _: lambda _: True)
monkeypatch.setattr(planner, "_build_partition_evaluator", lambda _: lambda _: partition_matches)
monkeypatch.setattr(table_module, "_open_manifest", open_manifest)
monkeypatch.setattr(ExecutorFactory, "get_or_create", staticmethod(lambda: _SerialExecutor()))
def evaluate_files() -> int:
return sum(len(entries) for entries in planner.plan_manifest_entries(manifests))
assert evaluate_files() == (67 if partition_matches else 0)
number = 1 if partition_matches else (1_000 if files_per_manifest == 1_000 else 10)
timings_ms = [timing * 1_000 / number for timing in timeit.repeat(evaluate_files, number=number, repeat=5)]
file_label = "file" if files_per_manifest == 1 else "files"
pruning_label = "all files pruned" if not partition_matches else "metrics evaluated"
print(
f"Evaluated metrics for {num_files} files with {files_per_manifest} {file_label} per manifest, "
f"a 102-column schema, and a 15-leaf predicate ({pruning_label}) in "
f"{statistics.mean(timings_ms):.3f}ms (best: {min(timings_ms):.3f}ms)"
)