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

Remove special-case handling of vec.split_off(0) #119917

Merged
merged 1 commit into from Jan 26, 2024

Conversation

Zalathar
Copy link
Contributor

#76682 added special handling to Vec::split_off for the case where at == 0. Instead of copying the vector's contents into a freshly-allocated vector and returning it, the special-case code steals the old vector's allocation, and replaces it with a new (empty) buffer with the same capacity.

That eliminates the need to copy the existing elements, but comes at a surprising cost, as seen in #119913. The returned vector's capacity is no longer determined by the size of its contents (as would be expected for a freshly-allocated vector), and instead uses the full capacity of the old vector.

In cases where the capacity is large but the size is small, that results in a much larger capacity than would be expected from reading the documentation of split_off. This is especially bad when split_off is called in a loop (to recycle a buffer), and the returned vectors have a wide variety of lengths.

I believe it's better to remove the special-case code, and treat at == 0 just like any other value:

  • The current documentation states that split_off returns a “newly allocated vector”, which is not actually true in the current implementation when at == 0.
  • If the value of at could be non-zero at runtime, then the caller has already agreed to the cost of a full memcpy of the taken elements in the general case. Avoiding that copy would be nice if it were close to free, but the different handling of capacity means that it is not.
  • If the caller specifically wants to avoid copying in the case where at == 0, they can easily implement that behaviour themselves using mem::replace.

Fixes #119913.

@rustbot
Copy link
Collaborator

rustbot commented Jan 13, 2024

r? @cuviper

(rustbot has picked a reviewer for you, use r? to override)

@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 13, 2024
@the8472
Copy link
Member

the8472 commented Jan 13, 2024

That eliminates the need to copy the existing elements, but comes at a surprising cost

That isn't really a cost since it's the same old allocation. I'd say the extra cost is the with_capacity call in for the now-empty self.

The current documentation states that split_off returns a “newly allocated vector”

😮‍💨 I wish people stopped documenting implementation details that aren't necessary to the function of a method.
But yes, given that existing documentation it makes sense.

I think we should ping @richkadel though, perhaps he can give arguments in favor of the existing code.

Also note that we now have mem::take() which is a simpler alternative to mem::replace().

@Zalathar
Copy link
Contributor Author

That isn't really a cost since it's the same old allocation. I'd say the extra cost is the with_capacity call in for the now-empty self.

The “cost” that I have in mind is not just the larger allocation for self, but also the fact that the returned vector might have far more capacity than is necessary to hold its contents.

In many cases, that cost won't matter. The returned vector might happen to be more than half-full, or it might be very short-lived, or it might be only a negligible fraction of the overall program's memory usage. But if split_off is called in a loop, and the size of the vector is often much less than its capacity, then that bloat can start to add up.

(If the user knows this quirk of the implementation, and wants to avoid it, they may end up having to copy the returned vector into a new vector with a more appropriate capacity. Doing so completely undermines the benefit of trying to avoid the copy in the first place.)

If the standard library decides to incur that extra cost on the user's behalf in the at == 0 case, we need to be confident that the benefit (avoiding memcpy) is worth it. I don't think we can justify that confidence.

(I think it's also significant that this choice of implementation only comes up for the at == 0 case. For any other value, the only reasonable implementation is to allocate a sensibly-sized buffer and memcpy the taken elements. That in turn makes it surprising that the at == 0 case might do something dramatically different.)

Also note that we now have mem::take() which is a simpler alternative to mem::replace().

Curiously, mem::take was stabilized in late 2019, but this special case was added in late 2020.

Calling split_off(0) is logically equivalent to calling mem::take, except for how it handles allocation/capacity. So this seems like another reason to avoid handling capacity in a non-obvious way.

@richkadel
Copy link
Contributor

Thank you for identifying this issue and raising some great points!

I really appreciate that @Zalathar took the time to read through my original defense in #76682, so I don't need to reiterate everything.

I do want to raise awareness (for anyone that didn't read through that PR) that there were rudimentary tests done to show the performance benefit of the change, for the use case I was trying to solve. So to revert it now will negatively impact existing code that currently depends on the behavior from 2021 until now.

In the PR, we did discuss the fact that the rustdoc for split_off() is underspecified, which I believe is still a problem.

I did mention mem::replace() in the PR, and I made the (at the time, unchallenged) argument that mem:: functions were not commonly used except in more advanced code. Neither that, nor mem::take(), sprung to mind, to me, at that time, regardless of when it was stablized. It just wasn't a common pattern that I saw being used for cases like these, in most of the compiler and operating system code I was working on at the time. The point being, someone can look at split_off() and its current rustdoc, and assume it would be just as efficient as mem::take(), and they'd have been very wrong. It just wasn't intuitive.

So to simply revert it puts us back in what I think is still a less than ideal place.

But I 100% agree that the capacity issues of both the initial value and the returned value are problematic.

My opinion is just mine, so I hope you get input from a few others, including @Mark-Simulacrum who approved the original change, but I suggest:

  • Either, don't revert the behavior (so existing code performance is not degraded), BUT update the rustdoc to explain its expected/permitted/intended(?) behavior(s) in the case of split_off(0) so a reader at least has a better chance of understanding the trade-offs, and the opportunity to decide if this is the right function for them;
  • OR, revert it (accept a modified version of this PR) but include changes to the rustdoc that warns that the implementation of split_off(0) copies the entire buffer into a new Vec, and recommends considering mem::take() or mem::replace() (replace would allow controlling the initial capacity).

If you change it, I think it's especially important to update the rustdoc this time around. Recommending the mem:: functions will really benefit developers considering these use cases.

@the8472
Copy link
Member

the8472 commented Jan 14, 2024

There are other possible approaches such as only moving the allocation when it's not significantly oversized.

@Zalathar
Copy link
Contributor Author

If you change it, I think it's especially important to update the rustdoc this time around. Recommending the mem:: functions will really benefit developers considering these use cases.

Yeah, regardless of whether this PR is accepted or not, it would be good to add suggestions for other similar options, especially in the 0 case:

  • mem::take will steal the entire contents+buffer, and avoids allocating a replacement capacity
  • mem::replace will steal the entire contents+buffer, and gives explicit control over what is left behind in its place
  • Vec::drain can remove an arbitrary slice, not just a suffix

Also as mentioned above, the current implementation is inconsistent with the “newly allocated vector” wording, so that tension ought to be resolved in some way.

@Zalathar
Copy link
Contributor Author

I have also found that https://doc.rust-lang.org/std/collections/index.html#sequences lists the performance of split_off(i) as O(n-i), which is inconsistent with the current implementation, as the capacity can be arbitrarily larger than n.

@the8472
Copy link
Member

the8472 commented Jan 15, 2024

As long as there is no call to realloc happening capacity shouldn't factor into performance since requesting uninitialized memory from the OS doesn't depend on the size of the allocation.

@cuviper
Copy link
Member

cuviper commented Jan 17, 2024

I think this makes sense, especially with doc additions to discuss the various capacity options. But it's enough of a change to warrant talking as a team.

@rustbot +I-libs-nominated

@cuviper cuviper added the I-libs-nominated The issue / PR has been nominated for discussion during a libs team meeting. label Jan 17, 2024
@Zalathar
Copy link
Contributor Author

Zalathar commented Jan 18, 2024

This was raised in https://rust-lang.zulipchat.com/#narrow/stream/259402-t-libs.2Fmeetings/topic/Meeting.202024-01-17/near/416062684, but that particular discussion didn't reach a specific conclusion/consensus before moving on to other nominated issues.

matthiaskrgr added a commit to matthiaskrgr/rust that referenced this pull request Jan 21, 2024
…s, r=dtolnay

Document some alternatives to `Vec::split_off`

One of the discussion points that came up in rust-lang#119917 is that some people use `Vec::split_off` in cases where they probably shouldn't, because the alternatives (like `mem::take`) are hard to discover.

This PR adds some suggestions to the documentation of `split_off` that should point people towards alternatives that might be more appropriate for their use-case.

I've deliberately tried to keep these changes as simple and uncontroversial as possible, so that they don't depend on how the team decides to handle the concerns raised in rust-lang#119917. That's why I haven't touched the existing documentation for `split_off`, and haven't added links to `split_off` to the documentation of other methods.
matthiaskrgr added a commit to matthiaskrgr/rust that referenced this pull request Jan 21, 2024
…s, r=dtolnay

Document some alternatives to `Vec::split_off`

One of the discussion points that came up in rust-lang#119917 is that some people use `Vec::split_off` in cases where they probably shouldn't, because the alternatives (like `mem::take`) are hard to discover.

This PR adds some suggestions to the documentation of `split_off` that should point people towards alternatives that might be more appropriate for their use-case.

I've deliberately tried to keep these changes as simple and uncontroversial as possible, so that they don't depend on how the team decides to handle the concerns raised in rust-lang#119917. That's why I haven't touched the existing documentation for `split_off`, and haven't added links to `split_off` to the documentation of other methods.
rust-timer added a commit to rust-lang-ci/rust that referenced this pull request Jan 22, 2024
Rollup merge of rust-lang#120180 - Zalathar:vec-split-off-alternatives, r=dtolnay

Document some alternatives to `Vec::split_off`

One of the discussion points that came up in rust-lang#119917 is that some people use `Vec::split_off` in cases where they probably shouldn't, because the alternatives (like `mem::take`) are hard to discover.

This PR adds some suggestions to the documentation of `split_off` that should point people towards alternatives that might be more appropriate for their use-case.

I've deliberately tried to keep these changes as simple and uncontroversial as possible, so that they don't depend on how the team decides to handle the concerns raised in rust-lang#119917. That's why I haven't touched the existing documentation for `split_off`, and haven't added links to `split_off` to the documentation of other methods.
@cuviper cuviper removed the I-libs-nominated The issue / PR has been nominated for discussion during a libs team meeting. label Jan 25, 2024
@cuviper
Copy link
Member

cuviper commented Jan 25, 2024

After a followup discussion, and with the docs already added in #120180, I think we're good!

@bors r+

@bors
Copy link
Contributor

bors commented Jan 25, 2024

📌 Commit a655558 has been approved by cuviper

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 Jan 25, 2024
bors added a commit to rust-lang-ci/rust that referenced this pull request Jan 26, 2024
…iaskrgr

Rollup of 9 pull requests

Successful merges:

 - rust-lang#117420 (Make `#![allow_internal_unstable(..)]` work with `stmt_expr_attributes`)
 - rust-lang#117678 (Stabilize `slice_group_by`)
 - rust-lang#119917 (Remove special-case handling of `vec.split_off(0)`)
 - rust-lang#120117 (Update `std::io::Error::downcast` return type)
 - rust-lang#120329 (RFC 3349 precursors)
 - rust-lang#120339 (privacy: Refactor top-level visiting in `NamePrivacyVisitor`)
 - rust-lang#120345 (Clippy subtree update)
 - rust-lang#120360 (Don't fire `OPAQUE_HIDDEN_INFERRED_BOUND` on sized return of AFIT)
 - rust-lang#120372 (Fix outdated comment on Box)

r? `@ghost`
`@rustbot` modify labels: rollup
@bors bors merged commit 772e80a into rust-lang:master Jan 26, 2024
11 checks passed
@rustbot rustbot added this to the 1.77.0 milestone Jan 26, 2024
rust-timer added a commit to rust-lang-ci/rust that referenced this pull request Jan 26, 2024
Rollup merge of rust-lang#119917 - Zalathar:split-off, r=cuviper

Remove special-case handling of `vec.split_off(0)`

rust-lang#76682 added special handling to `Vec::split_off` for the case where `at == 0`. Instead of copying the vector's contents into a freshly-allocated vector and returning it, the special-case code steals the old vector's allocation, and replaces it with a new (empty) buffer with the same capacity.

That eliminates the need to copy the existing elements, but comes at a surprising cost, as seen in rust-lang#119913. The returned vector's capacity is no longer determined by the size of its contents (as would be expected for a freshly-allocated vector), and instead uses the full capacity of the old vector.

In cases where the capacity is large but the size is small, that results in a much larger capacity than would be expected from reading the documentation of `split_off`. This is especially bad when `split_off` is called in a loop (to recycle a buffer), and the returned vectors have a wide variety of lengths.

I believe it's better to remove the special-case code, and treat `at == 0` just like any other value:
- The current documentation states that `split_off` returns a “newly allocated vector”, which is not actually true in the current implementation when `at == 0`.
- If the value of `at` could be non-zero at runtime, then the caller has already agreed to the cost of a full memcpy of the taken elements in the general case. Avoiding that copy would be nice if it were close to free, but the different handling of capacity means that it is not.
- If the caller specifically wants to avoid copying in the case where `at == 0`, they can easily implement that behaviour themselves using `mem::replace`.

Fixes rust-lang#119913.
@Zalathar Zalathar deleted the split-off branch January 26, 2024 22:18
wip-sync pushed a commit to NetBSD/pkgsrc-wip that referenced this pull request Mar 29, 2024
Pkgsrc changes:
 * Adapt checksums and patches.

Upstream chnages:

Version 1.77.0 (2024-03-21)
==========================

- [Reveal opaque types within the defining body for exhaustiveness checking.]
  (rust-lang/rust#116821)
- [Stabilize C-string literals.]
  (rust-lang/rust#117472)
- [Stabilize THIR unsafeck.]
  (rust-lang/rust#117673)
- [Add lint `static_mut_refs` to warn on references to mutable statics.]
  (rust-lang/rust#117556)
- [Support async recursive calls (as long as they have indirection).]
  (rust-lang/rust#117703)
- [Undeprecate lint `unstable_features` and make use of it in the compiler.]
  (rust-lang/rust#118639)
- [Make inductive cycles in coherence ambiguous always.]
  (rust-lang/rust#118649)
- [Get rid of type-driven traversal in const-eval interning]
  (rust-lang/rust#119044),
  only as a [future compatiblity lint]
  (rust-lang/rust#122204) for now.
- [Deny braced macro invocations in let-else.]
  (rust-lang/rust#119062)

Compiler
--------

- [Include lint `soft_unstable` in future breakage reports.]
  (rust-lang/rust#116274)
- [Make `i128` and `u128` 16-byte aligned on x86-based targets.]
  (rust-lang/rust#116672)
- [Use `--verbose` in diagnostic output.]
  (rust-lang/rust#119129)
- [Improve spacing between printed tokens.]
  (rust-lang/rust#120227)
- [Merge the `unused_tuple_struct_fields` lint into `dead_code`.]
  (rust-lang/rust#118297)
- [Error on incorrect implied bounds in well-formedness check]
  (rust-lang/rust#118553),
  with a temporary exception for Bevy.
- [Fix coverage instrumentation/reports for non-ASCII source code.]
  (rust-lang/rust#119033)
- [Fix `fn`/`const` items implied bounds and well-formedness check.]
  (rust-lang/rust#120019)
- [Promote `riscv32{im|imafc}-unknown-none-elf` targets to tier 2.]
  (rust-lang/rust#118704)
- Add several new tier 3 targets:
  - [`aarch64-unknown-illumos`]
    (rust-lang/rust#112936)
  - [`hexagon-unknown-none-elf`]
    (rust-lang/rust#117601)
  - [`riscv32imafc-esp-espidf`]
    (rust-lang/rust#119738)
  - [`riscv32im-risc0-zkvm-elf`]
    (rust-lang/rust#117958)

Refer to Rust's [platform support page][platform-support-doc]
for more information on Rust's tiered platform support.

Libraries
---------

- [Implement `From<&[T; N]>` for `Cow<[T]>`.]
  (rust-lang/rust#113489)
- [Remove special-case handling of `vec.split_off
  (0)`.](rust-lang/rust#119917)

Stabilized APIs
---------------

- [`array::each_ref`]
  (https://doc.rust-lang.org/stable/std/primitive.array.html#method.each_ref)
- [`array::each_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.array.html#method.each_mut)
- [`core::net`]
  (https://doc.rust-lang.org/stable/core/net/index.html)
- [`f32::round_ties_even`]
  (https://doc.rust-lang.org/stable/std/primitive.f32.html#method.round_ties_even)
- [`f64::round_ties_even`]
  (https://doc.rust-lang.org/stable/std/primitive.f64.html#method.round_ties_even)
- [`mem::offset_of!`]
  (https://doc.rust-lang.org/stable/std/mem/macro.offset_of.html)
- [`slice::first_chunk`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.first_chunk)
- [`slice::first_chunk_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.first_chunk_mut)
- [`slice::split_first_chunk`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.split_first_chunk)
- [`slice::split_first_chunk_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.split_first_chunk_mut)
- [`slice::last_chunk`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.last_chunk)
- [`slice::last_chunk_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.last_chunk_mut)
- [`slice::split_last_chunk`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.split_last_chunk)
- [`slice::split_last_chunk_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.split_last_chunk_mut)
- [`slice::chunk_by`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.chunk_by)
- [`slice::chunk_by_mut`]
  (https://doc.rust-lang.org/stable/std/primitive.slice.html#method.chunk_by_mut)
- [`Bound::map`]
  (https://doc.rust-lang.org/stable/std/ops/enum.Bound.html#method.map)
- [`File::create_new`]
  (https://doc.rust-lang.org/stable/std/fs/struct.File.html#method.create_new)
- [`Mutex::clear_poison`]
  (https://doc.rust-lang.org/stable/std/sync/struct.Mutex.html#method.clear_poison)
- [`RwLock::clear_poison`]
  (https://doc.rust-lang.org/stable/std/sync/struct.RwLock.html#method.clear_poison)

Cargo
-----

- [Extend the build directive syntax with `cargo::`.]
  (rust-lang/cargo#12201)
- [Stabilize metadata `id` format as `PackageIDSpec`.]
  (rust-lang/cargo#12914)
- [Pull out as `cargo-util-schemas` as a crate.]
  (rust-lang/cargo#13178)
- [Strip all debuginfo when debuginfo is not requested.]
  (rust-lang/cargo#13257)
- [Inherit jobserver from env for all kinds of runners.]
  (rust-lang/cargo#12776)
- [Deprecate rustc plugin support in cargo.]
  (rust-lang/cargo#13248)

Rustdoc
-----

- [Allows links in markdown headings.]
  (rust-lang/rust#117662)
- [Search for tuples and unit by type with `()`.]
  (rust-lang/rust#118194)
- [Clean up the source sidebar's hide button.]
  (rust-lang/rust#119066)
- [Prevent JS injection from `localStorage`.]
  (rust-lang/rust#120250)

Misc
----

- [Recommend version-sorting for all sorting in style guide.]
  (rust-lang/rust#115046)

Internal Changes
----------------

These changes do not affect any public interfaces of Rust, but they represent
significant improvements to the performance or internals of rustc and related
tools.

- [Add more weirdness to `weird-exprs.rs`.]
  (rust-lang/rust#119028)
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.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Unexpected/wasteful handling of capacity by vec.split_off(0)
6 participants