Skip to content

ChangeBatch::compact: sort with the stable sort again - #833

Open
gburd wants to merge 1 commit into
TimelyDataflow:masterfrom
gburd:changebatch-stable-sort
Open

gburd wants to merge 1 commit into
TimelyDataflow:masterfrom
gburd:changebatch-stable-sort

Conversation

@gburd

@gburd gburd commented Nov 7, 2026

Copy link
Copy Markdown

ChangeBatch::compact sorts updates, whose prefix updates[..clean] is already sorted and consolidated, followed by the updates added since. #751 moved it from sort_by to sort_unstable_by. The unstable sort sorts the whole list again. The stable sort finds the sorted run and merges the new updates into it in linear time. This PR goes back to sort_by here, with a comment saying why. The other call sites #751 changed are left as they are.

Measured on master (da506a1, v0.32.0), release build, with the program below: one input, a region of N maps (a chain) or N parallel maps concatenated (a fan), advanced one round at a time.

sort_unstable_by (master) sort_by (this PR)
chain, 2,000 operators: µs per round, median of 5 2,201 2,010 (−8.7%)
fan, 2,000 operators: µs per round, median of 5 5,624 4,872 (−13%)
chain, 100 rounds: instructions (callgrind) 2,256 M 1,800 M (−20%)
chain, 100 rounds: heap allocations (memcheck) 325,285 326,026 (+0.2%)

The stable sort allocates scratch only past its 4 KiB stack buffer, so small batches still allocate nothing. Only the large ones, where the merge pays off most, allocate.

An alternative I tried: sort only updates[clean..] in place (unstable), and run the stable sort only when that tail overlaps the prefix. It came out at 2,047 M instructions (−9%) and 325,488 allocations, so the plain stable sort wins.

Found while moving Improv from timely 0.13 to 0.32: an edit at the root of a 2,000-measure differential-dataflow graph had become 13–37% slower, almost all of it in ChangeBatch::compact. With this change it is back to within 5% of 0.13.

cargo test --workspace --all-targets, cargo test --doc and cargo clippy --workspace --all-targets pass, and cargo check on 1.86 (the MSRV).

The benchmark
use timely::dataflow::operators::{Concatenate, Enter, Input, Leave, Probe};
use timely::dataflow::operators::vec::Map;

fn main() {
    let mut a = std::env::args().skip(1);
    let shape = a.next().unwrap_or("chain".into());
    let ops: usize = a.next().map_or(2000, |s| s.parse().unwrap());
    let rounds: usize = a.next().map_or(2000, |s| s.parse().unwrap());
    timely::execute_directly(move |worker| {
        let (mut input, probe) = worker.dataflow::<usize, _, _>(|scope| {
            let (input, stream) = scope.new_input::<Vec<u64>>();
            let out = scope.region(|inner| {
                let s = stream.enter(inner);
                let out = if shape == "chain" {
                    let mut s = s;
                    for _ in 0..ops { s = s.map(|x| x + 1); }
                    s
                } else {
                    let branches: Vec<_> = (0..ops).map(|_| s.clone().map(|x| x + 1)).collect();
                    inner.concatenate(branches)
                };
                out.leave(scope)
            });
            let (probe, _s) = out.probe();
            (input, probe)
        });
        let t = std::time::Instant::now();
        for round in 1..=rounds {
            input.send(round as u64);
            input.advance_to(round);
            while probe.less_than(&round) { worker.step(); }
        }
        let e = t.elapsed();
        println!("{shape} {ops} ops, {rounds} rounds: {:.1} us/round", e.as_secs_f64() * 1e6 / rounds as f64);
    });
}

Run as progress-bench chain 2000 300 and progress-bench fan 2000 300.

compact sorts updates whose prefix updates[..clean] is already sorted and consolidated. TimelyDataflow#751 moved it to sort_unstable_by, which sorts the whole list again; the stable sort finds the sorted run and merges the new updates into it in linear time. On a 2,000-operator dataflow advanced one round at a time, progress tracking takes 20% fewer instructions (callgrind) and 9-13% less time per round, for 0.2% more heap allocations: the stable sort allocates scratch only past its 4 KiB stack buffer.
@frankmcsherry

Copy link
Copy Markdown
Member

Thanks! I can't seem to keep up with the Rust sort infrastructure (or what is real vs my memory about it).

I have a maybe helpful hint, if you are getting beaten up by these computations. The recent timely releases allow operators to express a FrontierInterest, which indicates whether they care to hear about progress tracking (which is where your cost comes from). The options have historically been Always or Never, but there's a new one now: IfCapability, which is allowed to go dark if there are no capabilities held by the operator. Of consequence, regions are operators, which wrap whole subgraphs, and they can go dark if their operators agree (their "frontier interest" is the strongest of their children).

There's a blog post here: https://github.com/frankmcsherry/blog/blob/master/posts/2026-03-19.md

The main caveat is that the DD half of the story isn't merged yet, but you could check it out if you like. It did reveal that the inspect operator, which does not need to see the frontier, was incorrectly saying that it needed to see this (it is build on a more general operator that does); this should be fixed with #834.

I tested a few improv workloads with a combination of 1. regions introduced, 2. the #834 patch above, and 3. the DD PR that makes its core operators frontier interest savvy, and regions of e.g. 50 joins go from needing ~6ms when "idle" to needing ~0.15ms. It only helps in those rounds when regions get no updates, and that depends on what you are doing whether this is often or rarely. It can be rare that there are zero updates at all, but not uncommon that some subcomputation do not receive any updates, so it can result in a general streamlining.

Happy to explain more, but also for the moment it's work in progress that should be landing, rather than released goods.

This branch has not been deployed

No deployments
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.

2 participants