Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update documentation of select_nth_unstable and select_nth_unstable_by to state O(n^2) complexity #106933

Merged
merged 1 commit into from
Feb 19, 2023

Conversation

schuelermine
Copy link
Contributor

See #102451

@rustbot
Copy link
Collaborator

rustbot commented Jan 16, 2023

Thanks for the pull request, and welcome! The Rust team is excited to review your changes, and you should hear from @cuviper (or someone else) soon.

Please see the contribution instructions for more information.

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Jan 16, 2023
@@ -2692,7 +2692,7 @@ impl<T> [T] {
/// This reordering has the additional property that any value at position `i < index` will be
/// less than or equal to any value at a position `j > index`. Additionally, this reordering is
/// unstable (i.e. any number of equal elements may end up at position `index`), in-place
/// (i.e. does not allocate), and *O*(*n*) worst-case. This function is also/ known as "kth
/// (i.e. does not allocate), and *O*(*n*^2) worst-case. This function is also/ known as "kth
Copy link
Member

@scottmcm scottmcm Jan 16, 2023

Choose a reason for hiding this comment

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

Are you sure the current implementation is actually O(n²) worse-case? It looks to me like it's doing a bunch of work in pivot selection to avoid being quadratic: EDIT: I'm wrong, and should have read the issue.

if len >= SHORTEST_MEDIAN_OF_MEDIANS {
// Finds the median of `v[a - 1], v[a], v[a + 1]` and stores the index into `a`.
let mut sort_adjacent = |a: &mut usize| {
let tmp = *a;
sort3(&mut (tmp - 1), a, &mut (tmp + 1));
};
// Find medians in the neighborhoods of `a`, `b`, and `c`.
sort_adjacent(&mut a);
sort_adjacent(&mut b);
sort_adjacent(&mut c);
}
// Find the median among `a`, `b`, and `c`.
sort3(&mut a, &mut b, &mut c);
}
if swaps < MAX_SWAPS {
(b, swaps == 0)
} else {
// The maximum number of swaps was performed. Chances are the slice is descending or mostly
// descending, so reversing will probably help sort it faster.
v.reverse();
(len - 1 - b, true)
}

At least I think that any documentation update here should emphasize that this is average-case O(n), since that's the point of the function existing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm relying on the statement that this is O(n²) worst-case from here: #102451

Copy link
Member

@scottmcm scottmcm Jan 16, 2023

Choose a reason for hiding this comment

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

Oh, just saw the discussion in the issue. And sadly it's not only O(n²) for malicious Ord either :(

Yeah, guess this needs to change to talk about the average and the worst-case separately.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I noted it. I’m not sure if select_nth_unstable_by_key is also affected.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps a better option would be to rewrite the algorithm based on one of the methods linked in the issue.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe as a first step here do the simple change to update the documentation just to change "worst-case" to "average"? Since the issue shows it's currently incorrect.

Then a follow-up PR could do the same fallback to heapsort that sort_unstable does to avoid being worse-cast O(n²).

@rustbot
Copy link
Collaborator

rustbot commented Jan 16, 2023

Hey! It looks like you've submitted a new PR for the library teams!

If this PR contains changes to any rust-lang/rust public library APIs then please comment with @rustbot label +T-libs-api -T-libs to tag it appropriately. If this PR contains changes to any unstable APIs please edit the PR description to add a link to the relevant API Change Proposal or create one if you haven't already. If you're unsure where your change falls no worries, just leave it as is and the reviewer will take a look and make a decision to forward on if necessary.

Examples of T-libs-api changes:

  • Stabilizing library features
  • Introducing insta-stable changes such as new implementations of existing stable traits on existing stable types
  • Introducing new or changing existing unstable library APIs (excluding permanently unstable features / features without a tracking issue)
  • Changing public documentation in ways that create new stability guarantees
  • Changing observable runtime behavior of library APIs

@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Jan 16, 2023
@rustbot
Copy link
Collaborator

rustbot commented Jan 16, 2023

Some changes occurred in src/tools/cargo

cc @ehuss

@schuelermine
Copy link
Contributor Author

oh f***. I ran x.py, but that introduced more changes…

@schuelermine schuelermine force-pushed the fix/doc/102451 branch 2 times, most recently from e8d1b07 to 85da540 Compare January 16, 2023 20:13
Copy link
Member

@cuviper cuviper left a comment

Choose a reason for hiding this comment

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

I’m not sure if select_nth_unstable_by_key is also affected.

I think it must be -- e.g. you could use T::clone to get the exact same complexity as select_nth_unstable (plus cloning overhead).

library/core/src/slice/mod.rs Outdated Show resolved Hide resolved
library/core/src/slice/mod.rs Outdated Show resolved Hide resolved
matthiaskrgr added a commit to matthiaskrgr/rust that referenced this pull request Jan 18, 2023
Add heapsort fallback in `select_nth_unstable`

Addresses rust-lang#102451 and rust-lang#106933.

`slice::select_nth_unstable` uses a quick select implementation based on the same pattern defeating quicksort algorithm that `slice::sort_unstable` uses. `slice::sort_unstable` uses a recursion limit and falls back to heapsort if there were too many bad pivot choices, to ensure O(n log n) worst case running time (known as introsort). However, `slice::select_nth_unstable` does not have such a fallback strategy, which leads to it having a worst case running time of O(n²) instead. rust-lang#102451 links to a playground which generates pathological inputs that show this quadratic behavior. On my machine, a randomly generated slice of length `1 << 19` takes ~200µs to calculate its median, whereas a pathological input of the same length takes over 2.5s. This PR adds an iteration limit to `select_nth_unstable`, falling back to heapsort, which ensures an O(n log n) worst case running time (introselect). With this change, there was no noticable slowdown for the random input, but the same pathological input now takes only ~1.2ms. In the future it might be worth implementing something like Median of Medians or Fast Deterministic Selection instead, which guarantee O(n) running time for all possible inputs. I've left this as a `FIXME` for now and only implemented the heapsort fallback to minimize the needed code changes.

I still think we should clarify in the `select_nth_unstable` docs that the worst case running time isn't currently O(n) (the original reason that rust-lang#102451 was opened), but I think it's a lot better to be able to guarantee O(n log n) instead of O(n²) for the worst case.
matthiaskrgr added a commit to matthiaskrgr/rust that referenced this pull request Jan 18, 2023
Add heapsort fallback in `select_nth_unstable`

Addresses rust-lang#102451 and rust-lang#106933.

`slice::select_nth_unstable` uses a quick select implementation based on the same pattern defeating quicksort algorithm that `slice::sort_unstable` uses. `slice::sort_unstable` uses a recursion limit and falls back to heapsort if there were too many bad pivot choices, to ensure O(n log n) worst case running time (known as introsort). However, `slice::select_nth_unstable` does not have such a fallback strategy, which leads to it having a worst case running time of O(n²) instead. rust-lang#102451 links to a playground which generates pathological inputs that show this quadratic behavior. On my machine, a randomly generated slice of length `1 << 19` takes ~200µs to calculate its median, whereas a pathological input of the same length takes over 2.5s. This PR adds an iteration limit to `select_nth_unstable`, falling back to heapsort, which ensures an O(n log n) worst case running time (introselect). With this change, there was no noticable slowdown for the random input, but the same pathological input now takes only ~1.2ms. In the future it might be worth implementing something like Median of Medians or Fast Deterministic Selection instead, which guarantee O(n) running time for all possible inputs. I've left this as a `FIXME` for now and only implemented the heapsort fallback to minimize the needed code changes.

I still think we should clarify in the `select_nth_unstable` docs that the worst case running time isn't currently O(n) (the original reason that rust-lang#102451 was opened), but I think it's a lot better to be able to guarantee O(n log n) instead of O(n²) for the worst case.
@schuelermine
Copy link
Contributor Author

OK, so I guess this is resolved now.

@cuviper
Copy link
Member

cuviper commented Jan 18, 2023

The documentation is still incorrect -- it's now average O(n) and worst-case O(n log n).

@schuelermine schuelermine reopened this Jan 18, 2023
@schuelermine schuelermine force-pushed the fix/doc/102451 branch 2 times, most recently from 3ab2f78 to 672aa43 Compare January 18, 2023 20:22
@rust-log-analyzer

This comment has been minimized.

@schuelermine schuelermine force-pushed the fix/doc/102451 branch 2 times, most recently from 77bfbb2 to 60c3b6a Compare January 20, 2023 22:47
@workingjubilee workingjubilee added the T-libs-api Relevant to the library API team, which will review and decide on the PR/issue. label Jan 22, 2023
@workingjubilee
Copy link
Member

Technically, this breaks an API promise, but one we never fulfilled and really could not fulfill as it is not truly possible to use a "comparison sort" as implied by the T: Ord bound and get better than O(n log n). Thus I tagged it for T-libs-api but the implications of this PR are more philosophical than ones requiring a decision.

@Sp00ph
Copy link
Member

Sp00ph commented Jan 22, 2023

It is definitely possible to get better than O(n log n) for comparison based selection, using something like median of medians which runs in O(n) worst case. Although it's slow enough that it's not worth it to always use it, we could definitely realistically use it as a fallback algorithm for our introselect, so we get the fast average case of quickselect but still guaranteed O(n)

@cuviper
Copy link
Member

cuviper commented Jan 22, 2023

r? libs-api

@rustbot rustbot assigned Amanieu and unassigned cuviper Jan 22, 2023
@workingjubilee
Copy link
Member

It is definitely possible to get better than O(n log n) for comparison based selection, using something like median of medians which runs in O(n) worst case.

Oh, fair enough! I actually thought of a few different other counterexamples but most of them had sufficient additional space complexity that it didn't seem worth mentioning, and after a few tens of minutes surveying things I was about ready to write them off.

Although it's slow enough that it's not worth it to always use it, we could definitely realistically use it as a fallback algorithm for our introselect, so we get the fast average case of quickselect but still guaranteed O(n).

Interesting. This seems like it'd be worth actually comparing the real performance on the options in some practical expected cases at various sizes, because it's not worth having O(n) if the O(n log n) beats it every time, but maybe the median-of-medians fallback would actually win out here.

@Sp00ph
Copy link
Member

Sp00ph commented Jan 22, 2023

I wrote a (very unoptimized) median-of-medians implementation a few days ago, and IIRC, using an input that caused quadratic behavior before #106997, it was slower to use as the introselect fallback than heapsort on a slice of length 1 << 19. It's kind of difficult to benchmark this because just creating an input that will even cause introselect to use its fallback algorithm takes a long time for large sizes (see the playground link in #102451). I unfortunately deleted my code, but I can try writing a more optimized median-of-medians later and doing some proper benchmarking against heapsort. Another alternative would be to use Fast Deterministic Selection which seems to have competitive performance with heuristic based quick select and also guarantees O(n) worst case runtime. This would mean a lot less code reuse between the selection and sorting algorithms though.

@Sp00ph
Copy link
Member

Sp00ph commented Jan 26, 2023

So I wrote this pretty simple median of medians implementation:

// the indices must all be in bounds and must not overlap.
// swaps elements around so the median of the 5 elements ends up in `v[c]`
unsafe fn median_of_five<T, F: FnMut(&T, &T) -> bool>(
    v: &mut [T],
    is_less: &mut F,
    a: usize,
    b: usize,
    c: usize,
    d: usize,
    e: usize,
) {
    let [a, b, c, d, e] = unsafe { v.get_many_unchecked_mut([a, b, c, d, e]) };
    let sort = |a: &mut T, b: &mut T, is_less: &mut F| {
        if is_less(b, a) {
            mem::swap(a, b);
        }
    };

    sort(a, c, is_less);
    sort(b, d, is_less);

    if is_less(c, d) {
        mem::swap(c, d);
        mem::swap(a, b);
    }

    sort(b, e, is_less);

    if is_less(c, e) {
        mem::swap(c, e);
        sort(a, c, is_less);
    } else {
        sort(b, c, is_less);
    }
}

pub fn select_linear<T, F: FnMut(&T, &T) -> bool>(mut v: &mut [T], is_less: &mut F, mut k: usize) {
    fn select_pivot<T, F: FnMut(&T, &T) -> bool>(v: &mut [T], is_less: &mut F) -> usize {
        debug_assert!(v.len() >= 5);

        let mut j = 0;
        let mut i = 0;
        while i + 4 < v.len() {
            unsafe { median_of_five(v, is_less, i, i + 1, i + 2, i + 3, i + 4) };
            unsafe { v.swap_unchecked(i + 2, j) };
            i += 5;
            j += 1;
        }

        select_linear(unsafe { v.get_unchecked_mut(..j) }, is_less, j / 2);
        partition(v, j / 2, is_less).0
    }

    loop {
        if v.len() <= 10 {
            insertion_sort(v, is_less);
            return;
        }

        let p = select_pivot(v, is_less);

        if p == k {
            return;
        } else if p > k {
            v = unsafe { v.get_unchecked_mut(..p) };
        } else {
            k -= p + 1;
            v = unsafe { v.get_unchecked_mut(p + 1..) };
        }
    }
}

and on my machine it becomes faster at computing the median than the stdlib heapsort at roughly 1 << 9 elements (on random input, but neither heapsort nor this algorithm depend a lot on the shape of the input). On very short inputs it looks to be roughly 2-3x slower. Using an index that's not close to the middle of the input slice also causes the performance to degrade. Do note that there are a ways to improve this implementation, of which some are described in the Fast Deterministic Selection paper.

@Sp00ph
Copy link
Member

Sp00ph commented Jan 27, 2023

I also now implemented the fast deterministic selection algorithm here. The implementation is entirely based on this paper (and this repo) except that I couldn't be bothered to implement expand_partition, and instead just used the existing stdlib partition. It completely blows heapsort out of the water. Even on very short slices it isn't much slower than heapsort, and on longer slices it becomes way faster. Random inputs of length 1 << 16 take ~4ms to sort using heapsort on my machine, whereas my fast deterministic selection implementation only takes ~70µs to select the median. Also, there is no noticable runtime degradation when choosing an index further from the middle of the slice as there was with median of medians. Note also that I paid absolutely no attention to optimization yet, so it may very well become even faster if we were to eliminate unnecessary bounds checks and such. The implementation also doesn't introduce that much new code (~200 lines, though with comments and optimizations that number will obviously grow).

In its current form it is a bit slower than select_nth_unstable on random input (though that might of course change with added optimizations), so I wouldn't (yet) consider always using it for selection, but for a fallback for introselect it looks very promising imo.

@Sp00ph
Copy link
Member

Sp00ph commented Jan 27, 2023

Is this even the right thread for this comment? Should I be posting it in the original issue thread instead?

@schuelermine
Copy link
Contributor Author

I just wanted to update the documentation. It’s probably better to discuss this on the original issue.

Copy link
Member

@Amanieu Amanieu left a comment

Choose a reason for hiding this comment

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

Some wording nits, but otherwise this doc change looks good to me.

library/core/src/slice/mod.rs Outdated Show resolved Hide resolved
…y and select_nth_unstable_by_key to state O(n log n) worst case complexity

Also remove erronious / in doc comment
@schuelermine
Copy link
Contributor Author

Updated the wording.

@Amanieu
Copy link
Member

Amanieu commented Feb 18, 2023

@bors r+ rollup

@bors
Copy link
Contributor

bors commented Feb 18, 2023

📌 Commit f1e649b has been approved by Amanieu

It is now in the queue for this repository.

@bors bors added S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Feb 18, 2023
bors added a commit to rust-lang-ci/rust that referenced this pull request Feb 19, 2023
Rollup of 7 pull requests

Successful merges:

 - rust-lang#104659 (reflow the stack size story)
 - rust-lang#106933 (Update documentation of select_nth_unstable and select_nth_unstable_by to state O(n^2) complexity)
 - rust-lang#107783 (rustdoc: simplify DOM for `.item-table`)
 - rust-lang#107951 (resolve: Fix doc links referring to other crates when documenting proc macro crates directly)
 - rust-lang#108130 ("Basic usage" is redundant for there is just one example)
 - rust-lang#108146 (rustdoc: hide `reference` methods in search index)
 - rust-lang#108189 (Fix some more `non_lifetime_binders` stuff with higher-ranked trait bounds)

Failed merges:

r? `@ghost`
`@rustbot` modify labels: rollup
@bors bors merged commit e802713 into rust-lang:master Feb 19, 2023
@rustbot rustbot added this to the 1.69.0 milestone Feb 19, 2023
compiler-errors added a commit to compiler-errors/rust that referenced this pull request May 25, 2023
Add Median of Medians fallback to introselect

Fixes rust-lang#102451.

This PR is a follow up to rust-lang#106997. It adds a Fast Deterministic Selection implementation as a fallback to the introselect algorithm used by `select_nth_unstable`. This allows it to guarantee O(n) worst case running time, while maintaining good performance in all cases.

This would fix rust-lang#102451, which was opened because the `select_nth_unstable` docs falsely claimed that it had O(n) worst case performance, even though it was actually quadratic in the worst case. rust-lang#106997 improved the worst case complexity to O(n log n) by using heapsort as a fallback, and this PR further improves it to O(n) (this would also make rust-lang#106933 unnecessary).
It also improves the actual runtime if the fallback gets called: Using a pathological input of size `1 << 19` (see the playground link in rust-lang#102451), calculating the median is roughly 3x faster using fast deterministic selection as a fallback than it is using heapsort.

The downside to this is less code reuse between the sorting and selection algorithms, but I don't think it's that bad. The additional algorithms are ~250 LOC with no `unsafe` blocks (I tried using unsafe to avoid bounds checks but it didn't noticeably improve the performance).
I also let it fuzz for a while against the current `select_nth_unstable` implementation to ensure correctness, and it seems to still fulfill all the necessary postconditions.

cc `@scottmcm` who reviewed rust-lang#106997
matthiaskrgr added a commit to matthiaskrgr/rust that referenced this pull request Jun 14, 2023
Update runtime guarantee for `select_nth_unstable`

rust-lang#106933 changed the runtime guarantee for `select_nth_unstable` from O(n) to O(n log n), since the old guarantee wasn't actually met by the implementation at the time. Now with rust-lang#107522, `select_nth_unstable` should be truly linear in runtime, so we can revert its runtime guarantee to O(n). Since rust-lang#106933 was considered a bug fix, this will probably need an FCP because it counts as a new API guarantee.

r? `@Amanieu`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. T-libs Relevant to the library team, which will review and decide on the PR/issue. T-libs-api Relevant to the library API team, which will review and decide on the PR/issue.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants