Skip to content

perf: reuse partition evaluator within manifests - #3656

Closed
dossett wants to merge 1 commit into
apache:mainfrom
dossett:reuse-partition-evaluator
Closed

perf: reuse partition evaluator within manifests#3656
dossett wants to merge 1 commit into
apache:mainfrom
dossett:reuse-partition-evaluator

Conversation

@dossett

@dossett dossett commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

I used CODEX to analyze this problem and create this PR. I've reviewed the code and test and stand by them. This summary is written completely by a human (me) other than very light copy editing by an LLM.

Each manifest is able to use its own _ExpressionEvaluator and they are not reused across manifests or workers for the same manifest. This is a performance win over creating one for each file because the constructor performs the binding of the partition predicate to the partition scheme. My local py-spy showed an order of magnitude more time being spent constructing the evaluator than running the evaluation.


During local scan planning, PyIceberg evaluates each data file's partition values against a projected scan predicate. Profiling showed that manifest workers were spending most of their active time reconstructing _ExpressionEvaluator and rebinding the same predicate for every file, while the actual partition evaluation was comparatively cheap.

The existing partition evaluator callable is cached per partition spec and shared across manifest worker threads. Because _ExpressionEvaluator.eval() mutates its current partition record, a single evaluator cannot safely be stored in that shared callable.

This change keeps the immutable partition schema and projected expression cached per spec, but caches a factory instead of a mutable evaluator. The factory creates a separate, lazily initialized evaluator for each manifest task. Files within that manifest are processed sequentially and reuse the evaluator, while concurrent manifests never share mutable state.

Performance

The focused benchmark evaluates 1,000 files using a two-field partition spec and a 66-leaf projected predicate. Results are the mean of three in-process runs:

Files per manifest main This PR Result
1,000 0.855 s 0.063 s ~13.6x faster
1 0.847 s 0.859 s Effectively unchanged

The second case exercises the boundary where no evaluator reuse is possible. It shows that creating a task-local closure does not introduce a meaningful regression when every manifest contains only one file.

This benchmark includes partition evaluator construction, predicate binding, and evaluation. It excludes catalog access, manifest Avro I/O and decompression, metrics evaluation, residual planning, and downstream data reads, so it is not an end-to-end query-runtime claim.

Testing

  • Added coverage proving a manifest callable constructs one evaluator and reuses it across files.
  • Added coverage proving the immutable factory is cached once per partition spec while separate manifests receive separate evaluators.
  • Added coverage proving reused evaluators replace their mutable partition-record state between files.
  • Added focused benchmarks for both many files per manifest and one file per manifest.
  • Planner and expression-evaluator tests: 183 passed.
  • Full unit suite: 3,770 passed, 1,560 deselected.
  • All repository hooks pass, including Ruff, mypy, pydocstyle, codespell, and lockfile validation.

There are no API or result-semantics changes.

AI assistance

Codex assisted with implementation, test scaffolding, benchmark design, validation, and PR drafting.

@dossett dossett closed this Jul 15, 2026
Fokko pushed a commit that referenced this pull request Jul 27, 2026
*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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant