| # 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)" | |
| ) |