perf(table): reuse row-group metrics maps - #1899
Conversation
laskoviymishka
left a comment
There was a problem hiding this comment.
Really nice change.
One blocking thing before merge: the nullCounts handling changes pruning behavior. The old code allocated a fresh nullCounts every call, so mayContainNull never hit its == nil branch and a field with no null stats was always prune-eligible. Now nullCounts starts nil and, once some row group populates it, later no-stats row groups run clear() and leave a non-nil empty map, so mayContainNull (and NOT STARTS WITH pruning through it) returns different results for identical row groups depending on call order. Since this is meant to be allocation-only, I'd restore the pre-change invariant by handling nullCounts the same as valueCounts (== nil -> make, else clear) and dropping the lazy make in the loop.
I'd also want the test to exercise that path: the lifecycle test only runs AlwaysTrue today, so the maps get built but the pruning code that reads them never runs, and the third-call assert.Empty passes for both nil and empty. A NOT STARTS WITH case across a no-stats, with-stats, no-stats sequence, asserting the result matches on the identical calls, would lock the contract.
The rest looks good and the benchmark structure is solid. Once those are addressed, happy to take another pass and approve.
| } else { | ||
| clear(m.valueCounts) | ||
| } | ||
| if m.nullCounts != nil { |
There was a problem hiding this comment.
I think this quietly changes pruning behavior, which is the one thing I'd want fixed before merge on an allocation-only PR.
Before this change nullCounts got a fresh make every call, so mayContainNull's if m.nullCounts == nil { return true } branch was effectively dead and a field with no null stats always came back false (prune-eligible). Now nullCounts starts nil and is only allocated once some row group has null stats; after that, a later no-null-stats row group runs clear() and leaves a non-nil empty map instead of nil. So two structurally identical row groups can return opposite results from mayContainNull depending on whether an earlier call populated it, and VisitNotStartsWith keys its pruning decision on exactly that.
Cleanest fix for a perf PR is to keep the pre-change invariant by handling nullCounts the same as valueCounts, and dropping the lazy make in the loop:
if m.nullCounts == nil {
m.nullCounts = make(map[int]int64, len(colIndices))
} else {
clear(m.nullCounts)
}valueCounts is already fine, and lowerBounds/upperBounds are fine too since every consumer reads them by key and nil-checks the returned []byte; nil and empty behave identically there. It's only mayContainNull that hard-branches on == nil. wdyt?
| m.nullCounts[fieldID] = stats.NullCount() | ||
| } | ||
| if stats.HasMinMax() { | ||
| if m.lowerBounds == nil { |
There was a problem hiding this comment.
While we're in here: upperBounds only ever gets allocated inside this lowerBounds == nil branch, so lowerBounds is effectively the sentinel for the pair. A one-line comment noting they're always allocated together would keep a future change from setting one without the other.
| } | ||
| } | ||
|
|
||
| func benchmarkTestRowGroupBefore(m *inclusiveMetricsEval, rgmeta *metadata.RowGroupMetaData, colIndices []int) (bool, error) { |
There was a problem hiding this comment.
benchmarkTestRowGroupBefore is a hand-copied snapshot of the old TestRowGroup, and nothing keeps it in sync. If TestRowGroup changes later, the "before" baseline silently goes stale. A one-line comment marking it an intentional frozen copy of the pre-#1899 implementation (that shouldn't track future changes) would save the confusion.
| func TestInclusiveMetricsEvalRowGroupMetricsLifecycle(t *testing.T) { | ||
| withStats := buildRowGroupMetricsMetadata(t, 1, 2, true) | ||
| withoutStats := buildRowGroupMetricsMetadata(t, 1, 2, false) | ||
| eval := &inclusiveMetricsEval{expr: iceberg.AlwaysTrue{}} |
There was a problem hiding this comment.
This lifecycle test only ever runs AlwaysTrue, so VisitTrue returns before any of these maps get consulted: the reuse paths get built up, but the pruning logic that actually reads them never runs.
I'd add a case that evaluates NOT STARTS WITH (or IS NULL/NOT NULL) across the same no-stats, with-stats, no-stats sequence and asserts the result is identical on the first and third calls. That's what locks the contract, and it would catch the nullCounts leak from the other comment. wdyt?
| require.NoError(t, err) | ||
| assert.True(t, keep) | ||
| assert.Empty(t, eval.valueCounts) | ||
| assert.Empty(t, eval.nullCounts) |
There was a problem hiding this comment.
assert.Empty passes for both a nil map and a non-nil empty one, so this third block doesn't distinguish "map reused and cleared" from "map reset to nil": it can't catch the behavior change above or prove the reuse invariant the PR is going for.
Exact assertions depend on which fix we take for nullCounts, but the shape I'd want is assert.NotNil + assert.Empty on the reused maps so it actually pins "same map, emptied." Heads up that flips the first-call assert.Nil(eval.nullCounts) too if we go the always-non-nil route.
bd323b8 to
e7e42ae
Compare
Summary
TestRowGroupcalls.valueCountsfrom the requested column count.Performance
Benchmark:
BenchmarkParquetRowGroupMetricsMapsApple M1 Pro, Go 1.26.3, 100 ms per case.
Numbers are local reference points and will vary by machine.
Tests
go test ./table/... -count=1go test -race ./table -count=1go vet ./tablego test ./table -run '^$' -bench '^BenchmarkParquetRowGroupMetricsMaps$' -benchmem -benchtime=100ms -count=1