Skip to content

compute: store temporal bucket updates time-major - #39760

Merged
antiguru merged 4 commits into
MaterializeInc:mainfrom
antiguru:moritz/temporal-bucket-time-major
Oct 15, 2026
Merged

antiguru merged 4 commits into
MaterializeInc:mainfrom
antiguru:moritz/temporal-bucket-time-major

Conversation

@antiguru

Copy link
Copy Markdown
Member

Posted by Claude Code on behalf of @antiguru.

Temporal bucket chunks were sorted by data, so every chunk spanned the bucket's times. Splitting a bucket at a time therefore loaded and rewrote every chunk, spilled ones included, and the bucket at the frontier splits several times per sweep.

This stores updates as ((time, data), time, diff) in the existing merge batcher. Each chunk then covers a narrow range of times, and seal at the midpoint passes every chunk whose time bounds don't straddle it through without loading it. The trailing time stays the batcher's time, so seal works unchanged. Revealed chunks convert back to (data, time, diff) columns one at a time. The cost is a second time column per stored record.

In a local measurement with 2M records over a 2^20-tick window, swept in 2^12-tick steps with the spill pool enabled, the slowest peel-and-restore step dropped from 65 ms to 8 ms and the whole sweep from 590 ms to 384 ms.

New unit tests peel_matches_reference_{resident,spilled} compare bucket chain peels against a reference accumulation, with retractions, colliding updates, and updates arriving for buckets that earlier peels split.

Independent of #39756, which commits the readied side of these seals. The two conflict textually at the batcher field.

🤖 Generated with Claude Code

Temporal bucket chunks were sorted by data, so every chunk spanned the
bucket's times. Splitting a bucket at a time therefore loaded and rewrote
every chunk, including spilled ones, and the frontier bucket splits several
times per sweep.

Store updates as `((time, data), time, diff)` in the existing merge batcher.
Each chunk then covers a narrow range of times, and `seal` at the midpoint
moves every chunk whose time bounds do not straddle it without loading it.
The trailing time stays the batcher's time, so `seal` and its time-bound
pass-through work unchanged. Revealed chunks convert back to
`(data, time, diff)` columns one at a time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
antiguru and others added 2 commits October 12, 2026 12:53
ColumnChunker emits one body per input column, of any size. ChunkChunker cuts each body at the batcher's byte bound, so an oversized staging column no longer enters the batcher as one resident chunk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@antiguru
antiguru requested a review from DAlperin October 12, 2026 14:44
@antiguru
antiguru marked this pull request as ready for review October 12, 2026 14:44
@antiguru
antiguru requested a review from a team as a code owner October 12, 2026 14:44
@def-

def- commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Per-bucket staging column holds a container-sized allocation for the bucket's lifetime

src/compute/src/extensions/temporal_bucket.rs:400

staging is a field of MergeBatcherWrapper, so every bucket in the chain gets one, and push_container only ever clear()s it, which keeps the leaf Vec capacities. Each bucket that has received a large push keeps a buffer sized to that push, plus the second time column, until the bucket is peeled. That memory is outside the spill pool and missing from the mz_arrangement_batcher_* accounting, which works against what this PR is trying to do.

Details

Before this PR the bucket held no per-push state. ColumnChunker hands its target off with mem::take, and the one reusable buffer belongs to the operator. Now push_container (:393-400) copies each push into self.staging and clears it afterwards. Column::clear on Typed calls the columnar Clear, which is Vec::clear and keeps capacity. A push is one bucket's share of an input container, and ColumnBuilder ships those at about 2 MiB (SHIP_WORDS). Amortized Vec growth can make the retained capacity up to twice the largest push. A chain spans roughly 30 buckets over the u64 domain, and during hydration of a temporal filter with a window of days, every bucket covering the window gets snapshot-sized pushes. On a split, the upper half keeps its staging and the lower half starts empty. So the retention stays in the far buckets, which live for as long as the window.

The total is about one container per bucket that has had data, per operator per worker. Every temporal-bucketed arrangement on every worker adds that much.

Suggested fix: have the operator's buffer hold the time-major ((T, D), T, R) layout directly. That removes staging and the extra copy. Alternatively, pass a single scratch column owned by the operator into push_container, the same way buffer is shared today. Dropping the allocation after each push (self.staging = Column::default()) also fixes it, but allocates on every push.

The time-major staging column was a field of every bucket and only ever
cleared, so each bucket that received a large push kept a push-sized
allocation until it was peeled. That memory sat outside the spill pool and
the batcher's size accounting. Build the staging container per push
instead and hand it to the chunker, which takes its body out by value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copy link
Copy Markdown
Member Author

Posted by Claude Code on behalf of @moritz*.*

Valid, fixed in 454fd16. The staging field is gone. push_container builds a fresh time-major container per push and hands it to the chunker, which takes its body out with mem::take, so no bucket retains a push-sized buffer. This costs one allocation per push. ColumnChunker already allocates its sort permutation per push, so the shared-scratch variant didn't seem worth the extra signature change.

@antiguru

Copy link
Copy Markdown
Member Author

The cost is a second time column per stored record.

I want to shine some light on this. Assuming we store rows in (Row, Timestamp, Diff), we need $$8\cdot \left|\text{row}\right|$$ bytes per row, and $$8+8$$ for the timestamp and diff. Including the timestamp twice adds another 8 byte. This is in the in-memory representation. With compression (lz4), it should consume less space, although that won't be enabled yet.

@frankmcsherry frankmcsherry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can see this seems fine. The trade-off as I understand it is a potential increase in memory, probably nominal, to reduce the time needed for temporal bucketing extraction. I'm not certain when we engage this mechanism, but probably in the cases where this trade-off is likely to be valuable anyhow. I don't think it's a clean win, but it seems fine.

@antiguru

Copy link
Copy Markdown
Member Author

Thanks for the review!

@antiguru
antiguru merged commit 6de726e into MaterializeInc:main Oct 15, 2026
83 checks passed
@antiguru
antiguru deleted the moritz/temporal-bucket-time-major branch October 15, 2026 16:01
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.

3 participants