Skip to content

Commit 23aa9a9

Browse files
authored
Make partition expression evaluation stateless (#3664)
*I used CODEX to analyze this problem and create this PR. I've reviewed the code and tests and stand by them. This summary is written completely by a human (me) other than very light copy editing by an LLM.* Currently, because `_ExpressionEvaluator` can't be safely shared one is created per file. That's expensive relative to actually evaluating the expression. This change makes `_ExpressionEvaluator` shareable by moving the mutable parts into a new `_ExpressionEvaluationVisitor` which stays local to the per-file work of evaluation. These benchmarks are showing improvements in sub-second times, but in the production workloads I tested this on it did translate to real wall clock improvement. But irrespective of performance gains the new `_ExpressionEvaluator` is safer since nothing was preventing its accidental concurrent reuse in the future. CODEX-generated summary follows: ------- Partition pruning currently binds the same projected expression for every data file because `_ExpressionEvaluator` stores the current record as mutable instance state. That repeated preparation is expensive, but sharing the existing evaluator across workers would allow concurrent calls to mix records. This separates preparation from per-record evaluation. `_ExpressionEvaluator` binds the expression once, while every call creates a private `_ExpressionEvaluationVisitor` containing that call's record. Manifest planning can therefore prepare one evaluator per partition spec and safely share it across manifests and workers. This provides the construction-reuse benefit targeted by #3656 without depending on files within a manifest remaining sequential, and it also benefits one-file manifests. ## Summary - Split the prepared expression evaluator from the call-local mutable visitor. - Reuse one prepared partition evaluator per partition spec during manifest planning. - Add deterministic concurrent-use, prepared-state, and planner-sharing coverage. - Add a benchmark using a realistic 15-leaf predicate across dense and one-file manifests. ## Performance I compared this branch with `main` using the partition-evaluator workload in this PR. It evaluates 1,000 files with two identity partition fields and a 15-leaf predicate modeling five `(event_day range AND region_id)` branches. Timings are medians from seven samples of five iterations. | Manifest layout | `main` | This PR | Speedup | |---|---:|---:|---:| | 1 manifest × 1,000 files | 187.928 ms | 14.413 ms | 13.04× | | 1,000 manifests × 1 file | 188.000 ms | 14.584 ms | 12.89× | The improvement comes from binding the projected partition expression once per partition spec instead of once per file. This is an isolated partition-pruning benchmark rather than an end-to-end scan-planning measurement. ## Testing - `pytest tests/expressions/test_visitors.py tests/table/test_partition_evaluator_planning.py tests/table/test_init.py` (200 passed) - `pytest tests/benchmark/test_partition_evaluator_benchmark.py -m benchmark` (2 passed)
1 parent a8f2771 commit 23aa9a9

5 files changed

Lines changed: 247 additions & 7 deletions

File tree

pyiceberg/expressions/visitors.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,16 +454,25 @@ def expression_evaluator(schema: Schema, unbound: BooleanExpression, case_sensit
454454
return _ExpressionEvaluator(schema, unbound, case_sensitive).eval
455455

456456

457-
class _ExpressionEvaluator(BoundBooleanExpressionVisitor[bool]):
457+
class _ExpressionEvaluator:
458+
"""An evaluator that binds an expression once and keeps evaluation state local to each call."""
459+
458460
bound: BooleanExpression
459-
struct: StructProtocol
460461

461462
def __init__(self, schema: Schema, unbound: BooleanExpression, case_sensitive: bool):
462463
self.bound = bind(schema, unbound, case_sensitive)
463464

464465
def eval(self, struct: StructProtocol) -> bool:
466+
return visit(self.bound, _ExpressionEvaluationVisitor(struct))
467+
468+
469+
class _ExpressionEvaluationVisitor(BoundBooleanExpressionVisitor[bool]):
470+
"""Evaluate a bound expression against one struct."""
471+
472+
struct: StructProtocol
473+
474+
def __init__(self, struct: StructProtocol):
465475
self.struct = struct
466-
return visit(self.bound, self)
467476

468477
def visit_in(self, term: BoundTerm, literals: set[L]) -> bool:
469478
return term.eval(self.struct) in literals

pyiceberg/table/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2679,11 +2679,11 @@ def _build_partition_evaluator(self, spec_id: int) -> Callable[[DataFile], bool]
26792679
partition_type = spec.partition_type(self.table_metadata.schema())
26802680
partition_schema = Schema(*partition_type.fields)
26812681
partition_expr = self.partition_filters[spec_id]
2682+
evaluator = expression_evaluator(partition_schema, partition_expr, self.case_sensitive)
26822683

2683-
# The lambda created here is run in multiple threads.
2684-
# So we avoid creating _EvaluatorExpression methods bound to a single
2685-
# shared instance across multiple threads.
2686-
return lambda data_file: expression_evaluator(partition_schema, partition_expr, self.case_sensitive)(data_file.partition)
2684+
# Expression evaluators keep input-specific state local to each call, so the
2685+
# prepared evaluator can be shared by every manifest using this spec.
2686+
return lambda data_file: evaluator(data_file.partition)
26872687

26882688
def _build_metrics_evaluator(self) -> Callable[[DataFile], bool]:
26892689
schema = self.table_metadata.schema()
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 a realistic 15-leaf partition predicate when a prepared evaluator is shared across manifests.
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 _data_file(file_number: int) -> DataFile:
42+
return DataFile.from_args(
43+
file_path=f"s3://bucket/data-{file_number}.parquet",
44+
file_format=FileFormat.PARQUET,
45+
partition=Record(file_number % 11, file_number % 15),
46+
record_count=100,
47+
file_size_in_bytes=1,
48+
)
49+
50+
51+
def _partition_filter() -> BooleanExpression:
52+
"""Select five day ranges, each scoped to a region."""
53+
windows = ((0, 1, 1), (2, 3, 4), (4, 5, 7), (6, 7, 10), (8, 10, 13))
54+
branches = [
55+
And(
56+
And(GreaterThanOrEqual("event_day", start_day), LessThanOrEqual("event_day", end_day)),
57+
EqualTo("region_id", region_id),
58+
)
59+
for start_day, end_day, region_id in windows
60+
]
61+
62+
combined = branches[0]
63+
for branch in branches[1:]:
64+
combined = Or(combined, branch)
65+
return combined
66+
67+
68+
@pytest.mark.benchmark
69+
@pytest.mark.parametrize(
70+
"files_per_manifest",
71+
[1_000, 1],
72+
ids=["many-files-per-manifest", "one-file-per-manifest"],
73+
)
74+
def test_partition_evaluator_reuse(table_v2: Table, files_per_manifest: int) -> None:
75+
num_files = 1_000
76+
schema = Schema(
77+
NestedField(1, "event_day", LongType(), required=True),
78+
NestedField(2, "region_id", LongType(), required=True),
79+
)
80+
spec = PartitionSpec(
81+
PartitionField(1, 1000, IdentityTransform(), "event_day"),
82+
PartitionField(2, 1001, IdentityTransform(), "region_id"),
83+
spec_id=0,
84+
)
85+
metadata = TableMetadataV2(
86+
location="s3://bucket/table",
87+
last_column_id=2,
88+
schemas=[schema],
89+
current_schema_id=schema.schema_id,
90+
partition_specs=[spec],
91+
default_spec_id=spec.spec_id,
92+
)
93+
planner = ManifestGroupPlanner(table_metadata=metadata, io=table_v2.io, row_filter=_partition_filter())
94+
data_files = [_data_file(file_number) for file_number in range(num_files)]
95+
96+
def evaluate_files() -> int:
97+
partition_evaluator = planner._build_partition_evaluator(spec.spec_id)
98+
matches = 0
99+
for start in range(0, num_files, files_per_manifest):
100+
matches += sum(partition_evaluator(data_file) for data_file in data_files[start : start + files_per_manifest])
101+
return matches
102+
103+
assert evaluate_files() == 67
104+
iterations = 100
105+
timings_ms = [timing * 1_000 / iterations for timing in timeit.repeat(evaluate_files, number=iterations, repeat=3)]
106+
file_label = "file" if files_per_manifest == 1 else "files"
107+
108+
print(
109+
f"Evaluated partitions for {num_files} files with {files_per_manifest} {file_label} per manifest "
110+
f"and a 15-leaf predicate in "
111+
f"{statistics.mean(timings_ms):.3f}ms (best: {min(timings_ms):.3f}ms)"
112+
)

tests/expressions/test_visitors.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# under the License.
1717
# pylint:disable=redefined-outer-name
1818

19+
from concurrent.futures import ThreadPoolExecutor
20+
from threading import Event
1921
from typing import Any
2022

2123
import pytest
@@ -67,6 +69,7 @@
6769
BindVisitor,
6870
BooleanExpressionVisitor,
6971
BoundBooleanExpressionVisitor,
72+
_ExpressionEvaluator,
7073
_ManifestEvalVisitor,
7174
expression_evaluator,
7275
expression_to_plain_format,
@@ -1630,6 +1633,57 @@ def test_expression_evaluator_null() -> None:
16301633
assert expression_evaluator(schema, NotStartsWith("a", 1), case_sensitive=True)(struct) is True
16311634

16321635

1636+
def test_expression_evaluator_does_not_mutate_prepared_state() -> None:
1637+
schema = Schema(
1638+
NestedField(1, "a", IntegerType(), required=True),
1639+
NestedField(2, "b", IntegerType(), required=True),
1640+
)
1641+
evaluator = _ExpressionEvaluator(schema, And(EqualTo("a", 1), EqualTo("b", 1)), case_sensitive=True)
1642+
initial_state = vars(evaluator).copy()
1643+
1644+
assert evaluator.eval(Record(1, 1)) is True
1645+
assert evaluator.eval(Record(0, 0)) is False
1646+
assert evaluator.eval(Record(1, 1)) is True
1647+
1648+
assert vars(evaluator) == initial_state
1649+
1650+
1651+
def test_expression_evaluator_concurrent_calls_do_not_share_records() -> None:
1652+
class BlockingRecord(Record):
1653+
def __init__(self, first_read: Event, release_first_read: Event, *values: Any) -> None:
1654+
super().__init__(*values)
1655+
self.first_read = first_read
1656+
self.release_first_read = release_first_read
1657+
1658+
def __getitem__(self, pos: int) -> Any:
1659+
value = super().__getitem__(pos)
1660+
if pos == 0:
1661+
self.first_read.set()
1662+
if not self.release_first_read.wait(timeout=5):
1663+
raise TimeoutError("Timed out waiting to interleave expression evaluations")
1664+
return value
1665+
1666+
schema = Schema(
1667+
NestedField(1, "a", IntegerType(), required=True),
1668+
NestedField(2, "b", IntegerType(), required=True),
1669+
)
1670+
evaluator = expression_evaluator(schema, And(EqualTo("a", 1), EqualTo("b", 1)), case_sensitive=True)
1671+
first_read = Event()
1672+
release_first_read = Event()
1673+
1674+
with ThreadPoolExecutor(max_workers=2) as executor:
1675+
matching_result = executor.submit(evaluator, BlockingRecord(first_read, release_first_read, 1, 1))
1676+
assert first_read.wait(timeout=5)
1677+
1678+
try:
1679+
non_matching_result = executor.submit(evaluator, Record(0, 0)).result(timeout=5)
1680+
finally:
1681+
release_first_read.set()
1682+
1683+
assert matching_result.result(timeout=5) is True
1684+
assert non_matching_result is False
1685+
1686+
16331687
def test_expression_evaluator_binary_starts_with() -> None:
16341688
schema = Schema(NestedField(1, "x", BinaryType(), required=False), schema_id=1)
16351689
struct = Record(b"aa")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.manifest import DataFile, FileFormat
27+
from pyiceberg.schema import Schema
28+
from pyiceberg.table import ManifestGroupPlanner, Table
29+
from pyiceberg.typedef import Record, StructProtocol
30+
31+
32+
def _data_file(file_number: int, partition_value: int) -> DataFile:
33+
return DataFile.from_args(
34+
file_path=f"s3://bucket/data-{file_number}.parquet",
35+
file_format=FileFormat.PARQUET,
36+
partition=Record(partition_value),
37+
record_count=100,
38+
file_size_in_bytes=1,
39+
)
40+
41+
42+
def test_partition_evaluator_prepares_once_per_spec(table_v2: Table, monkeypatch: pytest.MonkeyPatch) -> None:
43+
evaluator_calls: list[list[int]] = []
44+
45+
def counting_expression_evaluator(
46+
schema: Schema, unbound: BooleanExpression, case_sensitive: bool
47+
) -> Callable[[StructProtocol], bool]:
48+
calls: list[int] = []
49+
evaluator_calls.append(calls)
50+
51+
def evaluate(struct: StructProtocol) -> bool:
52+
value = struct[0]
53+
calls.append(value)
54+
return value > 5
55+
56+
return evaluate
57+
58+
monkeypatch.setattr(table_module, "expression_evaluator", counting_expression_evaluator)
59+
planner = ManifestGroupPlanner(table_metadata=table_v2.metadata, io=table_v2.io, row_filter=GreaterThan("x", 5))
60+
partition_evaluator = planner._build_partition_evaluator(0)
61+
62+
assert len(evaluator_calls) == 1
63+
assert not partition_evaluator(_data_file(1, 1))
64+
assert partition_evaluator(_data_file(2, 10))
65+
assert evaluator_calls == [[1, 10]]

0 commit comments

Comments
 (0)