Skip to content

compute: bucket temporally on the columnar edge - #38371

Merged
antiguru merged 4 commits into
columnar-tf-join-core-builderfrom
columnar-tg-temporal-bucket-native
Sep 12, 2026
Merged

compute: bucket temporally on the columnar edge#38371
antiguru merged 4 commits into
columnar-tf-join-core-builderfrom
columnar-tg-temporal-bucket-native

Conversation

@antiguru

@antiguru antiguru commented Aug 20, 2026

Copy link
Copy Markdown
Member

Makes temporal bucketing columnar throughout, removing the four Vec round-trips its call sites paid. The operator's state was already columnar, since its bucket chain wraps ColumnMergeBatcher, so the decode and re-encode existed only at its boundary.

Three per-record costs go with the round-trips. The reveal path handed the output builder owned tuples reconstituted from the chain's Column chunks, and now gives each chunk as a container. MergeBatcherWrapper::push_container staged its Vec argument into a Column one record at a time before the chunker could read it, and now takes a Column. Bucket::split round-tripped the sealed chunks through a Vec, cloning every record, and now re-chunks the containers.

On the input side, records are addressed by index through a permutation rather than moved, so neither the pass-through nor the bucket routing materializes an owned record. The partition happens before the sort, because only retained records need ordering and in steady state the pass-through share is the larger one.

TemporalBucketing::bucket now returns Self, which lets a Vec stream and a columnar stream both implement it. The reduce key-value path stays on the Vec implementation, which stages through the columnar operator so there is one bucketing implementation rather than two. It feeds an arrangement, and a consumer that re-encodes what it reads is cheaper to hand moved allocations than copied bytes.

Worth knowing for review: enable_compute_temporal_bucketing is compiled in as false but served on in production, so this is a live path rather than a dormant one. CPU-213 tracks reconciling the default. Verified with the flag defaulted on, since the compiled-in default otherwise leaves the operator untested outside temporal_bucketing.slt.

Posted by Claude Code

@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from d7c0cb0 to b980c29 Compare August 20, 2026 12:58
@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

CPU-51

@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch 2 times, most recently from e37c599 to e163635 Compare September 10, 2026 11:34
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from e163635 to 8f98052 Compare September 10, 2026 12:04
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 8f98052 to aea209c Compare September 10, 2026 12:09
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from aea209c to d02623b Compare September 10, 2026 14:25
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from d02623b to 9bd1e54 Compare September 10, 2026 14:39
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 9bd1e54 to dd94790 Compare September 10, 2026 18:37
@antiguru
antiguru added this pull request to stack #37750 September 10, 2026 18:38
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from dd94790 to bdf0852 Compare September 10, 2026 18:40
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from bdf0852 to 51d7f71 Compare September 10, 2026 20:30
permutation.clear();
for index in 0..borrowed.len() {
let update = borrowed.get(index);
if upper.less_equal(&T::into_owned(update.1)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think the trick is to have a one-element container for upper, and compare the Ref type directly. Check if that's possible.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not taken yet, and I want to check it properly rather than guess. upper is an Antichain<T>, and less_equal wants a &T, so comparing a Ref directly needs PartialOrder on the Ref type, which columnar does not provide for a Product<Timestamp, PointStamp<u64>>. A one-element container gives us Ref on both sides, but the comparison still has to come from somewhere.

Took the cheaper half of the win in the meantime: the allocation, not the comparison. time_buf.copy_from(update.1) reuses one T for both the upper test and the bucket placement, so the loop no longer allocates a PointStamp per record. What is left is the copy itself, a few words.

Will look at whether a Ref-level less_equal is expressible, most likely as a small trait on our side implemented for the timestamp types the bucketer actually sees, and report back rather than holding this PR for it.

Posted by Claude Code

let mut buffered_range = None;
for index in permutation.drain(..) {
let update = borrowed.get(index);
let update_time = T::into_owned(update.1);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Try to use Columnar::copy_from instead of creating a new owned object.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. One time_buf now serves both reads of a record's time, the upper test and the bucket placement, so the pass allocates nothing per record.

Posted by Claude Code

@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 51d7f71 to 48a7764 Compare September 10, 2026 20:50
Comment on lines +50 to +54
///
/// Columnar throughout. The chain's batcher already holds [`Column`] chunks, so
/// the reveal path moves containers, and the input side addresses records by
/// index through a time-ordered permutation rather than moving them. No owned
/// record is materialized on either path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

don't need to comment this anymore

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dropped, along with the two sibling comments that said the same thing at the call sites in render.rs and top_k.rs. Those now say only when bucketing fires. The implementation's doc paragraph went too: the permutation it described is already noted where the permutation is built, which is where it belongs.

Pushed as doc: drop the comments the columnar bucketing made vacuous.

Posted by Claude Code.


use super::*;
use crate::render::columnar::vec_to_columnar;
use crate::render::columnar::{columnar_to_vec, vec_to_columnar};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

??

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test-only. The bucketing tests capture their output for comparison, and capture() wants a stream of owned records, so columnar_to_vec is how the assertion reads a columnar edge back. No decode is added to a rendered dataflow by this PR.

There is a note on one of those tests saying why the decode is not allowed to hide a bug: agreeing contents alone would not rule out a silent decode on the ok path, so the tests assert on the edge's container type as well.

Posted by Claude Code.

Comment thread src/compute/src/render/top_k.rs Outdated
);
}
// Temporal bucketing is `Vec`-internal, so decode in and encode out. It fires
// Temporal bucketing is columnar throughout, so no round trip here. It fires

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

dont need to say that

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone. Both this one and the matching line in render.rs now say only when bucketing fires, not what its containers are.

Posted by Claude Code.

Comment thread src/compute/src/render.rs Outdated
// encode out, keeping this Union input a columnar edge.
let os = columnar_to_vec(os);
vec_to_columnar(T::maybe_apply_temporal_bucketing(
// Temporal bucketing is columnar throughout, so no round trip

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

||

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Gone. Both this one and the matching line in render.rs now say only when bucketing fires, not what its containers are.

Posted by Claude Code.

@antiguru
antiguru marked this pull request as ready for review September 11, 2026 17:57
@antiguru
antiguru requested a review from a team as a code owner September 11, 2026 17:57
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 4e21ab6 to 790d556 Compare September 11, 2026 18:53
@def-

def- commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- the reduce path's _vec bucketing now copies every record four times where it previously moved them

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

The StreamVec implementation no longer has an operator body: it encodes every record into a Column (BucketStage), runs the columnar operator, then decodes every record back into owned Rows (BucketUnstage). The old Vec-native operator moved pass-through records end to end. Since render_reduce is the only caller and temporal bucketing is served on in production, a pass-through record on the temporal-filter → reduce path goes from zero copies to three byte-copies plus a decode with two Row allocations.

Details

Per pass-through record, before: Exchange pact (ExchangeCore<CapacityContainerBuilder<Vec<_>>, _>, timely pact.rs:57) moves the owned tuple, extract_if moves it, give_iterator moves it into the output Vec. Intra-process exchange does not serialize, so nothing is copied.

After: BucketStage copies the record into a column and drops the owned tuple (two Row frees per record for D = (Row, Row)); the columnar ExchangeCore<ColumnBuilder<_>, _> re-encodes it per destination; session.give(update) at temporal_bucket.rs:117 copies it into the output column; BucketUnstage decodes it back with into_owned, allocating two Rows. Retained records pay two extra copies on top of what they already paid.

Cross-process exchange is a partial offset (Vec containers bincode-serialize, columns do not), but single-process replicas are common and there the extra work is unconditional.

The doc on this impl says "The staging copy is the price of a Vec caller, paid here rather than at the call site", and the PR body says a re-encoding consumer "is cheaper to hand moved allocations than copied bytes". Neither holds: the call site paid no such price before, because the Vec-native operator existed, and the allocations BucketUnstage hands the arrangement were just decoded from a column this change introduced.

Fix: keep a Vec-native operator body for this impl (two implementations, as before), or move the reduce call site onto the columnar operator and decode once at the arrangement boundary. Note that dropping BucketStage by having the reduce key/val operator emit a column directly is not free either -- it currently uses ConsolidatingContainerBuilder (reduce.rs:111), which consolidates in the container, and a ColumnBuilder does not.

2. LOW -- split's comment claims it "visits no record", but re-chunking visits and sorts every record

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

push_container hands each chunk to ColumnChunker::push_into, which materializes a Vec of one Ref per record, sorts it, and re-pushes every record into a fresh target column (src/timely-util/src/columnar/batcher.rs:167). The per-record fuel charge on the line below the comment is there precisely because the work is per-record. What the change removed is the owned (D, T, R) round-trip, not the visit; the comment's own first sentence ("Re-chunks the sealed chunks") already says so.

@antiguru

Copy link
Copy Markdown
Member Author

Both findings are real. Fixed in compute: keep a Vec-native operator for the Vec bucketing caller.

1, the _vec path. Confirmed, including the premise: the repo default for enable_compute_temporal_bucketing is false, but LaunchDarkly has it launched and on in production and staging, so the reduce path is live and this was a real regression on it.

The Vec stream gets its own operator body back. Records near the frontier pass through as moves, and only records that enter the chain are encoded, which they were before this PR as well since the chain's batcher is columnar. The columnar implementation stays for the columnar callers.

You also caught something I should have caught myself. The doc justified the staging with "a Vec is the cheaper intermediate for consumers that re-encode", which is a rule I retracted on #38368 after reading the consumers it was written about, and I then carried it into this file anyway. The doc now says only that a caller whose consumer wants owned records keeps a Vec-native operator, and that this implementation goes away once reduce's consumer reads columns.

Not taking the second option you offer, moving the reduce call site onto the columnar operator. Its bucketed output feeds render_reduce_plan, the whole reduce tree rather than a single arrange, so that is a layer of its own rather than part of this PR. One correction to the note attached to it: ColumnBuilder indeed does not consolidate, but ConsolidatingColumnBuilder does, and it is what the rest of the stack uses, so in-container consolidation is not the obstacle there.

2, the split comment. Right, and the per-record fuel charge on the next line contradicted it in place. It now says the chunker sorts and re-pushes every record, and that what the columnar chain removed is the owned-tuple round trip rather than the visit.

temporal_bucketing.slt 24/24, explain/default.slt 139/139, explain/physical_plan_as_json.slt 46/46, clippy clean.

Posted by Claude Code.

@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 5ee47da to 963f532 Compare September 11, 2026 20:31
@antiguru
antiguru requested a review from a team as a code owner September 11, 2026 21:16
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from 963f532 to e483030 Compare September 11, 2026 21:16
antiguru and others added 4 commits September 11, 2026 17:47
Temporal bucketing consumed and produced a `Vec` stream, so each of its
four call sites decoded the columnar edge into it and re-encoded the
result. The operator's own state was already columnar, since its bucket
chain wraps `ColumnMergeBatcher`, so the round-trips existed only at its
boundary.

The operator now consumes and produces `Column`, and the four sites hand
it the edge directly. Three per-record costs go with the round-trips:

* The reveal path handed the builder owned tuples reconstituted from the
  chain's `Column` chunks. It now gives each chunk as a container.
* `MergeBatcherWrapper::push_container` staged its `Vec` argument into a
  `Column` one record at a time before the chunker could read it. It
  takes a `Column` now, so the caller's buffer is what the chunker reads.
* `Bucket::split` round-tripped the sealed chunks through a `Vec`, cloning
  every record on the way. It re-chunks the containers.

The input side addresses records by index through a permutation rather
than moving them, so neither the pass-through nor the bucket routing
materializes an owned record. Records are partitioned before that
permutation is sorted, because only the retained ones need ordering and
in steady state the pass-through share is the larger one.

`TemporalBucketing::bucket` returns `Self` rather than a chosen container,
which lets a `Vec` stream and a columnar stream both implement it. The
reduce key-value path keeps the `Vec` implementation, which stages through
the columnar operator so there is one bucketing implementation rather than
two. That path feeds an arrangement, and a re-encoding consumer is cheaper
to hand moved allocations than copied bytes, so `MaybeBucketByTime` offers
it as a separate `_vec` method rather than converting it.
The bucketing pass reads each record's time twice, once to test it against
the upper and once to place it in a bucket. An iterative `T` owns a
`PointStamp`'s allocation, so `Columnar::into_owned` paid for one per read.
`copy_from` refills a buffer instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With bucketing columnar on both sides there is no round trip left to point at,
so the call sites in `render.rs` and `top_k.rs` say only when bucketing fires.
The implementation's doc loses its paragraph as well: the permutation it
described is already noted where the permutation is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staging a `Vec` caller's whole stream through a column copies every pass-through
record in, copies it out, and allocates it again on the decode. Records near the
frontier never enter the chain, so that work buys nothing. Give the `Vec` stream
its own operator body again, which passes those records through as moves, and
encode only what the chain stores, which the columnar batcher required anyway.

The `split` comment claimed the re-chunk visits no record. The chunker sorts and
re-pushes every one, which is what the per-record fuel charge below it accounts
for. What the columnar chain removed is the owned-tuple round trip, not the visit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the columnar-tg-temporal-bucket-native branch from e483030 to 8780d40 Compare September 11, 2026 21:47
@antiguru
antiguru merged commit 2564b84 into main Sep 12, 2026
83 checks passed
@antiguru
antiguru deleted the columnar-tg-temporal-bucket-native branch September 12, 2026 00:16
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