perf(table): borrow manifest partition summaries - #1908
Conversation
laskoviymishka
left a comment
There was a problem hiding this comment.
This is a clean optimization and the capability-token shape is exactly right: the zero-size internal.ManifestFileRef gating the borrow, the per-call ev copy that keeps the borrowed slice safe under concurrent scans, and the graceful fallback to public Partitions() for external ManifestFile implementations. I walked the borrow lifetime through the Visit* methods and I'm satisfied the read-only/no-retain contract holds, so the change itself is correct.
I'd fix couple things before merge, mostly on the tests. The zero-alloc assertion uses InDelta(0.0, allocs, 0.5), which passes even if the path allocates 50 times per 100 runs. That's the single guarantee this PR exists to make, and CI wouldn't catch a regression in it today. The borrowed-view test also checks values and alloc count but never proves the view actually aliases the manifest, so a shallow-copy refactor would pass it silently. Those two are the guard rails for the thing we're optimizing, so I'd tighten both here.
Separately, the PR description mentions a TestManifestEvalVisitorEvalRace, but I don't see it in the diff. Since the borrow's safety under parallel scans rests entirely on the per-call ev copy, I'd like that race test actually present so the concurrency claim is verified by CI rather than by reading.
A few things I'd want to settle before merge:
- make the zero-alloc test assert exactly zero (
assert.Zero), notInDelta(0.5) - add an aliasing assertion that mutates through a borrowed bound and shows the manifest sees it
- either include the race test the description references, or drop the reference
- move the dispatch helper into
internalalongside theDataFileRefborrow helpers so other packages can reuse it - give each benchmark summary its own bounds instead of sharing three pointers
- rename
manifest_file_refs.goto the singularmanifest_file_ref.goto match its siblings
Once those are in, happy to take another pass and approve.
| // ManifestFileRef authorizes zero-copy access to immutable manifest state from | ||
| // trusted packages within this module. Go's internal-package rule prevents | ||
| // external callers from constructing this token. | ||
| type ManifestFileRef struct{} |
There was a problem hiding this comment.
The read-only/no-retain contract lives on manifestFilePartitions, but "trusted packages within this module" is really any in-module package, and any of them can construct this token and call the method directly, bypassing the helper.
Since the token is the thing a caller holds, I'd repeat the invariant here: borrowed, read-only, don't retain past the current operation. That way the contract travels with the capability rather than living only on one entry point.
| // bounds alias the manifest and must be treated as read-only for the current | ||
| // operation. | ||
| func (m *manifestFile) ManifestFilePartitionRef(_ internal.ManifestFileRef) []FieldSummary { | ||
| if m.PartitionList == nil { |
There was a problem hiding this comment.
This returns the same nil/empty result as Partitions() only because of an invariant that isn't written down anywhere: when PartitionList is non-nil it always points at a non-nil slice (the builder never stores &nilSlice, and the Avro path normalizes through ensurePartitionList).
If that ever stops holding, the borrowed path diverges from Partitions() silently. I'd add a one-line comment stating the invariant here so a future change doesn't break it unknowingly.
| } | ||
|
|
||
| b.ReportAllocs() | ||
| b.ReportMetric(float64(manifestCount), "manifests") |
There was a problem hiding this comment.
These two report static dimensions, so the output reads "100 manifests/op" where the /op implies a per-iteration rate that isn't one. The counts are already in the sub-benchmark name (manifests=100/fields=8), so I'd just drop both ReportMetric calls.
|
|
||
| summaries := make([]iceberg.FieldSummary, fieldCount) | ||
| for i := range summaries { | ||
| summaries[i] = iceberg.FieldSummary{ |
There was a problem hiding this comment.
Every summary here shares the same three pointers (&containsNaN, &lower, &upper). It's safe today only because ManifestBuilder.Partitions() deep-clones each entry via cloneFieldSummaries. But this PR is specifically about borrowing those bounds without copying, so a fixture that aliases across elements is exactly the footgun the borrowed path could trip on if this pattern gets copied into a test that skips the builder. I'd give each element its own copy inside the loop (fresh bool, slices.Clone the bounds).
| iceberginternal "github.com/apache/iceberg-go/internal" | ||
| ) | ||
|
|
||
| type manifestFilePartitionRef interface { |
There was a problem hiding this comment.
The DataFileRef pattern already merged keeps all these borrow helpers in internal (BorrowedDataFileStats, BorrowedDataFileBounds, and friends), so any trusted package in the module can reach zero-copy access without redeclaring the interface. This one lives in table, which means the next caller that wants borrowed partition summaries (scan planning, metrics) has to redeclare the interface and dispatch from scratch. I'd move it to internal as BorrowedManifestFilePartitions(ManifestFile) []FieldSummary following that model, at which point this file collapses to a one-line call.
While we're here, the unexported interface manifestFilePartitionRef and its method ManifestFilePartitionRef differ only by the leading case, which is easy to misread at a glance. Something like manifestPartitionBorrower would read more clearly. wdyt?
| partitions := manifestFilePartitions(manifest) | ||
| require.Len(t, partitions, 1) | ||
| assert.Equal(t, []byte{1, 2}, *partitions[0].LowerBound) | ||
| assert.Equal(t, []byte{3, 4}, *partitions[0].UpperBound) |
There was a problem hiding this comment.
This proves the values are right and (below) that the call doesn't allocate, but it never proves the view actually aliases the manifest. A future refactor that returned a shallow copy sharing the LowerBound pointers, or a pooled deep copy, would pass this test unchanged, so it doesn't distinguish "truly borrowed" from "cheap but not aliasing", which is the property the whole PR turns on.
I'd add an assertion that mutates through the borrowed pointer and shows the manifest sees it: grab partitions[0].LowerBound, write to (*partitions[0].LowerBound)[0], then read the manifest's stored bound back and assert it changed. Label it as the aliasing hazard callers must not trigger, so it documents the contract and locks in the borrow at the same time.
| allocs := testing.AllocsPerRun(100, func() { | ||
| partitions = manifestFilePartitions(manifest) | ||
| }) | ||
| assert.InDelta(t, 0.0, allocs, 0.5) |
There was a problem hiding this comment.
AllocsPerRun(100, ...) returns total allocations divided by 100, so InDelta(0.0, allocs, 0.5) passes with up to 50 allocations across the run. A regression that allocated on every other call would sail straight through it.
Since a genuine zero-alloc path returns exactly 0.0, I'd assert that directly:
assert.Zero(t, allocs)This is the guarantee the test exists to make, so it's worth making it exact.
What
manifestEvalVisitor.Eval.ManifestFile.Partitions()for external implementations.ManifestFile.Partitions()defensive-copy behavior unchanged.Why
ManifestFile.Partitions()copies the full summary slice, every lower and upper bound, and everyContainsNaNpointer. The manifest evaluator only reads these values during the current evaluation, so those copies are unnecessary on the built-in manifest path.Benchmark
Command:
Median of 5 runs on an Apple M1 Pro with Go 1.26.3:
This is about 57% lower latency, 81% fewer bytes, and 83% fewer allocations in this wide-manifest evaluator workload.
The benchmark also covers 1, 100, and 1,000 manifests with 1, 8, and 32 partition fields.
Testing
go test ./table -count=1 -timeout=5mgo test ./table -race -run '^(TestManifestFilePartitions|TestManifestEvaluator|TestManifestEvalVisitorEvalRace)' -count=1 -timeout=5mgo test ./... -run '^$' -count=1go vet ./table