Skip to content

Commit a0e7f4c

Browse files
committed
perf: reuse partition evaluator within manifests
1 parent 2c75523 commit a0e7f4c

3 files changed

Lines changed: 270 additions & 9 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
KeyDefaultDict,
9696
Properties,
9797
Record,
98+
StructProtocol,
9899
TableVersion,
99100
)
100101
from pyiceberg.types import strtobool
@@ -2582,10 +2583,11 @@ def plan_manifest_entries(self, manifests: Iterable[ManifestFile]) -> Iterator[l
25822583
manifest_file for manifest_file in manifests if manifest_evaluators[manifest_file.partition_spec_id](manifest_file)
25832584
]
25842585

2585-
# step 2: filter the data files in each manifest
2586-
# this filter depends on the partition spec used to write the manifest file
2586+
# step 2: filter the data files in each manifest using a task-local partition evaluator
25872587

2588-
partition_evaluators: dict[int, Callable[[DataFile], bool]] = KeyDefaultDict(self._build_partition_evaluator)
2588+
partition_evaluator_factories: dict[int, Callable[[], Callable[[DataFile], bool]]] = KeyDefaultDict(
2589+
self._build_partition_evaluator_factory
2590+
)
25892591

25902592
min_sequence_number = _min_sequence_number(manifests)
25912593

@@ -2596,7 +2598,7 @@ def plan_manifest_entries(self, manifests: Iterable[ManifestFile]) -> Iterator[l
25962598
(
25972599
self.io,
25982600
manifest,
2599-
partition_evaluators[manifest.partition_spec_id],
2601+
partition_evaluator_factories[manifest.partition_spec_id](),
26002602
self._build_metrics_evaluator(),
26012603
)
26022604
for manifest in manifests
@@ -2659,16 +2661,26 @@ def _build_manifest_evaluator(self, spec_id: int) -> Callable[[ManifestFile], bo
26592661
spec = self.table_metadata.specs()[spec_id]
26602662
return manifest_evaluator(spec, self.table_metadata.schema(), self.partition_filters[spec_id], self.case_sensitive)
26612663

2662-
def _build_partition_evaluator(self, spec_id: int) -> Callable[[DataFile], bool]:
2664+
def _build_partition_evaluator_factory(self, spec_id: int) -> Callable[[], Callable[[DataFile], bool]]:
26632665
spec = self.table_metadata.specs()[spec_id]
26642666
partition_type = spec.partition_type(self.table_metadata.schema())
26652667
partition_schema = Schema(*partition_type.fields)
26662668
partition_expr = self.partition_filters[spec_id]
26672669

2668-
# The lambda created here is run in multiple threads.
2669-
# So we avoid creating _EvaluatorExpression methods bound to a single
2670-
# shared instance across multiple threads.
2671-
return lambda data_file: expression_evaluator(partition_schema, partition_expr, self.case_sensitive)(data_file.partition)
2670+
# The schema and expression are immutable and can be cached per spec, while
2671+
# each manifest task gets its own mutable evaluator.
2672+
def partition_evaluator_factory() -> Callable[[DataFile], bool]:
2673+
evaluator: Callable[[StructProtocol], bool] | None = None
2674+
2675+
def partition_evaluator(data_file: DataFile) -> bool:
2676+
nonlocal evaluator
2677+
if evaluator is None:
2678+
evaluator = expression_evaluator(partition_schema, partition_expr, self.case_sensitive)
2679+
return evaluator(data_file.partition)
2680+
2681+
return partition_evaluator
2682+
2683+
return partition_evaluator_factory
26722684

26732685
def _build_metrics_evaluator(self) -> Callable[[DataFile], bool]:
26742686
schema = self.table_metadata.schema()
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""Benchmark partition evaluation across the sequential entries in one manifest.
18+
19+
Run with:
20+
uv run pytest tests/benchmark/test_partition_evaluator_benchmark.py -v -s -m benchmark
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import statistics
26+
import timeit
27+
28+
import pytest
29+
30+
from pyiceberg.expressions import And, BooleanExpression, EqualTo, GreaterThanOrEqual, LessThanOrEqual, Or
31+
from pyiceberg.manifest import DataFile, FileFormat
32+
from pyiceberg.partitioning import PartitionField, PartitionSpec
33+
from pyiceberg.schema import Schema
34+
from pyiceberg.table import ManifestGroupPlanner, Table
35+
from pyiceberg.table.metadata import TableMetadataV2
36+
from pyiceberg.transforms import IdentityTransform
37+
from pyiceberg.typedef import Record
38+
from pyiceberg.types import LongType, NestedField
39+
40+
41+
def _combined_filter() -> BooleanExpression:
42+
branches: list[BooleanExpression] = []
43+
for value in range(11):
44+
branch: BooleanExpression = GreaterThanOrEqual("x", 0)
45+
for predicate in (
46+
LessThanOrEqual("x", 10),
47+
EqualTo("y", value),
48+
EqualTo("y", value + 1),
49+
EqualTo("y", value + 2),
50+
EqualTo("y", value + 3),
51+
):
52+
branch = And(branch, predicate)
53+
branches.append(branch)
54+
55+
combined = branches[0]
56+
for branch in branches[1:]:
57+
combined = Or(combined, branch)
58+
return combined
59+
60+
61+
def _data_file(file_number: int) -> DataFile:
62+
return DataFile.from_args(
63+
file_path=f"s3://bucket/data-{file_number}.parquet",
64+
file_format=FileFormat.PARQUET,
65+
partition=Record(file_number % 11, file_number % 15),
66+
record_count=100,
67+
file_size_in_bytes=1,
68+
)
69+
70+
71+
@pytest.mark.benchmark
72+
@pytest.mark.parametrize(
73+
"files_per_manifest",
74+
[1_000, 1],
75+
ids=["many-files-per-manifest", "one-file-per-manifest"],
76+
)
77+
def test_partition_evaluator_reuse(table_v2: Table, files_per_manifest: int) -> None:
78+
num_files = 1_000
79+
schema = Schema(
80+
NestedField(1, "x", LongType(), required=True),
81+
NestedField(2, "y", LongType(), required=True),
82+
)
83+
spec = PartitionSpec(
84+
PartitionField(1, 1000, IdentityTransform(), "x"),
85+
PartitionField(2, 1001, IdentityTransform(), "y"),
86+
spec_id=0,
87+
)
88+
metadata = TableMetadataV2(
89+
location="s3://bucket/table",
90+
last_column_id=2,
91+
schemas=[schema],
92+
current_schema_id=schema.schema_id,
93+
partition_specs=[spec],
94+
default_spec_id=spec.spec_id,
95+
)
96+
planner = ManifestGroupPlanner(table_metadata=metadata, io=table_v2.io, row_filter=_combined_filter())
97+
data_files = [_data_file(file_number) for file_number in range(num_files)]
98+
partition_evaluator_factory = planner._build_partition_evaluator_factory(spec.spec_id)
99+
100+
def evaluate_files() -> int:
101+
matches = 0
102+
for start in range(0, num_files, files_per_manifest):
103+
partition_evaluator = partition_evaluator_factory()
104+
matches += sum(partition_evaluator(data_file) for data_file in data_files[start : start + files_per_manifest])
105+
return matches
106+
107+
assert evaluate_files() == 0
108+
timings = timeit.repeat(evaluate_files, number=1, repeat=3)
109+
file_label = "file" if files_per_manifest == 1 else "files"
110+
111+
print(
112+
f"Evaluated partitions for {num_files} files with {files_per_manifest} {file_label} per manifest "
113+
f"and a 66-leaf predicate in "
114+
f"{statistics.mean(timings):.3f}s (best: {min(timings):.3f}s)"
115+
)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
from collections.abc import Callable
21+
22+
import pytest
23+
24+
import pyiceberg.table as table_module
25+
from pyiceberg.expressions import BooleanExpression, GreaterThan
26+
from pyiceberg.io import FileIO
27+
from pyiceberg.manifest import DataFile, FileFormat, ManifestContent, ManifestEntry, ManifestFile
28+
from pyiceberg.schema import Schema
29+
from pyiceberg.table import ManifestGroupPlanner, Table
30+
from pyiceberg.typedef import Record, StructProtocol
31+
32+
33+
def _data_file(file_number: int, partition_value: int) -> DataFile:
34+
return DataFile.from_args(
35+
file_path=f"s3://bucket/data-{file_number}.parquet",
36+
file_format=FileFormat.PARQUET,
37+
partition=Record(partition_value),
38+
record_count=100,
39+
file_size_in_bytes=1,
40+
)
41+
42+
43+
def _manifest_file(file_number: int) -> ManifestFile:
44+
return ManifestFile.from_args(
45+
manifest_path=f"s3://bucket/manifest-{file_number}.avro",
46+
manifest_length=1,
47+
partition_spec_id=0,
48+
content=ManifestContent.DATA,
49+
sequence_number=1,
50+
min_sequence_number=1,
51+
added_snapshot_id=1,
52+
)
53+
54+
55+
def test_partition_evaluator_reuses_instance_per_manifest_callable(table_v2: Table, monkeypatch: pytest.MonkeyPatch) -> None:
56+
evaluator_calls: list[list[int]] = []
57+
58+
def counting_expression_evaluator(
59+
schema: Schema, unbound: BooleanExpression, case_sensitive: bool
60+
) -> Callable[[StructProtocol], bool]:
61+
calls: list[int] = []
62+
evaluator_calls.append(calls)
63+
64+
def evaluate(struct: StructProtocol) -> bool:
65+
calls.append(struct[0])
66+
return True
67+
68+
return evaluate
69+
70+
monkeypatch.setattr(table_module, "expression_evaluator", counting_expression_evaluator)
71+
planner = ManifestGroupPlanner(table_metadata=table_v2.metadata, io=table_v2.io, row_filter=GreaterThan("x", 5))
72+
first_file = _data_file(1, 1)
73+
second_file = _data_file(2, 10)
74+
75+
partition_evaluator_factory = planner._build_partition_evaluator_factory(0)
76+
first_callable = partition_evaluator_factory()
77+
assert not evaluator_calls
78+
assert first_callable(first_file)
79+
assert first_callable(second_file)
80+
81+
second_callable = partition_evaluator_factory()
82+
assert len(evaluator_calls) == 1
83+
assert second_callable(first_file)
84+
85+
assert evaluator_calls == [[1, 10], [1]]
86+
87+
88+
def test_manifest_group_planner_creates_partition_evaluator_per_manifest(
89+
table_v2: Table, monkeypatch: pytest.MonkeyPatch
90+
) -> None:
91+
planner = ManifestGroupPlanner(table_metadata=table_v2.metadata, io=table_v2.io, row_filter=GreaterThan("x", 5))
92+
built_factories: list[int] = []
93+
built_evaluators: list[Callable[[DataFile], bool]] = []
94+
opened_evaluators: list[Callable[[DataFile], bool]] = []
95+
96+
def build_partition_evaluator_factory(spec_id: int) -> Callable[[], Callable[[DataFile], bool]]:
97+
built_factories.append(spec_id)
98+
99+
def partition_evaluator_factory() -> Callable[[DataFile], bool]:
100+
def partition_evaluator(data_file: DataFile) -> bool:
101+
return True
102+
103+
built_evaluators.append(partition_evaluator)
104+
return partition_evaluator
105+
106+
return partition_evaluator_factory
107+
108+
def open_manifest(
109+
io: FileIO,
110+
manifest: ManifestFile,
111+
partition_evaluator: Callable[[DataFile], bool],
112+
metrics_evaluator: Callable[[DataFile], bool],
113+
) -> list[ManifestEntry]:
114+
opened_evaluators.append(partition_evaluator)
115+
return []
116+
117+
monkeypatch.setattr(planner, "_build_manifest_evaluator", lambda _: lambda _: True)
118+
monkeypatch.setattr(planner, "_build_partition_evaluator_factory", build_partition_evaluator_factory)
119+
monkeypatch.setattr(table_module, "_open_manifest", open_manifest)
120+
121+
list(planner.plan_manifest_entries([_manifest_file(1), _manifest_file(2)]))
122+
123+
assert built_factories == [0]
124+
assert len(built_evaluators) == 2
125+
assert {id(evaluator) for evaluator in opened_evaluators} == {id(evaluator) for evaluator in built_evaluators}
126+
127+
128+
def test_reused_partition_evaluator_replaces_file_state(table_v2: Table) -> None:
129+
planner = ManifestGroupPlanner(table_metadata=table_v2.metadata, io=table_v2.io, row_filter=GreaterThan("x", 5))
130+
partition_evaluator = planner._build_partition_evaluator_factory(0)()
131+
132+
assert not partition_evaluator(_data_file(1, 1))
133+
assert partition_evaluator(_data_file(2, 10))
134+
assert not partition_evaluator(_data_file(3, 2))

0 commit comments

Comments
 (0)