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

vec.reserve() unintuitive behavior #23842

Closed
ghost opened this issue Mar 29, 2015 · 15 comments · Fixed by #26955
Closed

vec.reserve() unintuitive behavior #23842

ghost opened this issue Mar 29, 2015 · 15 comments · Fixed by #26955
Labels
I-needs-decision Issue: In need of a decision.

Comments

@ghost
Copy link

ghost commented Mar 29, 2015

There are three issues I see with vec.reserve()

Issue 1: The results can be vastly different for minimally different input vectors. For example this code:

fn main() {
    let mut x = vec![0i8; 1023];
    x.reserve(1);
    let mut y = vec![0i8; 1024];
    y.reserve(1);
    println!("x capacity = {}, y capacity = {}", x.capacity(), y.capacity());
}

prints "x capacity = 1024, y capacity = 2048". The inputs to such a function are very similar, so we should expect reserve() to behave similarly on them, but we see that reserve() assigns special significance to powers of 2 for no reason.

Suggested fix: if we need to increase capacity past the current capacity, simply double the current capacity. So above, our result would be: "x capacity = 2046, y capacity = 2048".


Issue 2: How exactly the vector decides to expand can have huge performance considerations. See for example https://github.com/rust-lang/rust/blob/master/src/libcollections/vec.rs#L536. Also see for example the thread at #23820 (comment). All the API guarantees is that the vector will accommodate at least the size requested. However, in both of these situations, it is critical for performance reasons that the algorithm generally give you more for you ask for, and in the pull request I referenced, if you don't have the doubling behavior, you go from O(n) to O(n^2). It seems like such an important performance consideration should not be hidden in the implementation.

Recommendation: Document in the API that, if reserve() needs to allocate memory less than double its current capacity, it will double its current capacity.


Issue 3: The difference between reserve() and reserve_exact() seem confusing, mostly due to the name of the former. I'm sure there will be plenty of examples that crop up as well of people using reserve() where they meant to use reserve_exact(), or using reserve() and reasoning that it behaves like reserve_exact() (the pull request shows an example of this).

Recommendation: Change the name of reserve() to something more specific, so that people don't get confused and use reserve() where they mean to use reserve_exact(). Something like reserve_round_up().


I'm currently testing a commit that fixes a few issues in vec.rs (involving expansion behavior near usize::MAX). I can start working on fixing issues 1 and 2 now, though it seems like a large-ish API change like issue 3 (and maybe 2) would require an RFC. Let me know if there's enough interest to be worth submitting.

@ghost ghost mentioned this issue Mar 29, 2015
@emberian
Copy link
Member

cc @gankro, @aturon

@ghost
Copy link
Author

ghost commented Mar 30, 2015

Also see #23847 which clears up some of the confusion from #23820, but would still benefit from the changes discussed here.

@Gankra
Copy link
Contributor

Gankra commented Mar 30, 2015

Suggested fix: if we need to increase capacity past the current capacity, simply double the current capacity. So above, our result would be: "x capacity = 2046, y capacity = 2048".

Seems reasonable; note that this is subtler for HashMap and VecDeque because they require that capacity be a power-of-two implementation-wise.

Recommendation: Document in the API that, if reserve() needs to allocate memory less than double its current capacity, it will double its current capacity.

Amortization only requires that it grows by a constant factor; I see little value in specifying doubling (indeed there are many that argue that >= 2 is awful because repeated grows can't re-use any space (2^k >= [sum i=0 to k-1 of 2^i]). If you need tighter control of reservation use reserve_exact. If you just need amortization over many ops, don't even bother reserving, the collection will do appropriate amortization for you.

In general we've been very careful not to overspecify these APIs. Note that the general API is especially fuzzy because of HashMap, which you can't efficiently compute capacity for due to impl details. Also it may be desirable for a HashMap to reallocate if a long collision chain occurs. I'm not fundamentally opposed to tightening up the specific guarantees Vec offers, but I'd like to tread carefully here.

Recommendation: Change the name of reserve() to something more specific, so that people don't get confused and use reserve() where they mean to use reserve_exact(). Something like reserve_round_up().

Eh. We used to have the names reserve (now reserve_exact) and reserve_additional (now reserve). In practice this seemed backwards since empirically reserve_additional was what people wanted (I have some buffer and am anticipating k more items, do at most one reallocation for inserting this). Also it's not like it's a huge semantics foot-gun. At worst using reserve inappropriately wastes some space, right?

@ghost
Copy link
Author

ghost commented Mar 30, 2015

Seems reasonable; note that this is subtler for HashMap and VecDeque because they require that capacity be a power-of-two implementation-wise.

Shouldn't be too bad, as long as it starts with a power of two it will remain a power of two. I can take a look at it.

Amortization only requires that it grows by a constant factor I see little value in specifying doubling

I agree. So I think it should note in the API that it will grow by an implementation-defined constant factor.

As of right now even that isn't in the API, and theoretically the algorithm could change to something like allocating twice as much space as you ask for, or allocating exactly as much as you ask for, which would hurt run time complexities of functions which use reserve() (eg read_to_end referenced above).

Also it's not like it's a huge semantics foot-gun. At worst using reserve inappropriately wastes some space, right?

I'm not sure if this is the proper approach to this. It should be up to the user to decide what problems/inefficiencies are tolerable, not the library writers. Considering this is something of a systems language I'd imagine there are plenty of use cases where accidentally allocating twice as much memory as you meant to could be a very bad thing.

Given the ambiguous name of reserve() this is an error that's sure to happen repeatedly. Having eg reserve_round_up and reserve_exact, and no function just called reserve, removes the ambiguity entirely and basically removes that whole class of errors. Additionally, there seems to be a large upside and little to no downside to changing the names. We could just mark reserve() as deprecated and have reserve_round_up perform the same job.

@sfackler
Copy link
Member

C++'s vector::reserve appears to be basically identical to Vec::reserve as it currently exists.

@Gankra
Copy link
Contributor

Gankra commented Mar 30, 2015

They are different, though. vector::reserve is absolute while Vec::reserve is relative to current len.

@ghost
Copy link
Author

ghost commented Mar 30, 2015

I think C++ reserve behaves like reserve_exact in Rust. Note that reserve_exact doesn't guarantee you exactly the sized buffer you asked for either, it guarantees one at least that large. reserve_exact and C++ reserve, however, don't try to actively request a lot more bytes than you need the way Rust reserve does AFAIK.

@bluss
Copy link
Member

bluss commented Mar 31, 2015

I don't see why Issue 1 is a problem or needs a change. I'm sure the documentation will tell you the vector grows using that algorithm. Later we have said we would explore growth factors below 2.0.

@ghost
Copy link
Author

ghost commented Mar 31, 2015

I don't see why Issue 1 is a problem or needs a change.

Check out https://github.com/rust-lang/rust/blob/master/src/libcollections/vec.rs#L538

Let's say we have a vector with length and capacity 1023, and call insert twice. Right now it will do two full reallocs, the first of which is basically useless and only gives you one additional element in space. If you did the doubling algorithm (or multiplying by any constant factor) instead of rounding up to powers of 2, this would only need one realloc.

I'm sure the documentation will tell you the vector grows using that algorithm.

All the documentation says is:

Reserves capacity for at least additional more elements to be inserted in the given Vec. The collection may reserve more space to avoid frequent reallocations.

This doesn't even guarantee amortization (or anything really) which is why I suggest the API/documention specifies that if the reserve requires less than double (or any constant factor) times its current capacity, it will increase it's capacity by multiplying it by that constant factor.

@bluss
Copy link
Member

bluss commented Mar 31, 2015

Let's say we have a vector with length and capacity 1023, and call insert twice.

This will only occur if exactly 1023 was requested with with_capacity() (or from vec![], I admit). During regular growth without user requests for sizing, it would already have capacity 1024 at that point.

@ghost
Copy link
Author

ghost commented Mar 31, 2015

It could happen other ways. One or several calls to reserve_exact is another way.

It could happen through push() as well, since push has doubling behavior. Ie, if you start with 19 elements, and push enough elements such that it doubles twice, you end up with (19^2)^2 = 130321 elements. If you call reserve(1) on that when it's full, it will increase to 2^17 = 131072 elements, which is a whole lot of copying for only gaining 751 elements of additional space.

@bluss
Copy link
Member

bluss commented Mar 31, 2015

That is clearly broken if push and insert don't agree on growth strategy. I'm sorry.

@steveklabnik steveklabnik added I-needs-decision Issue: In need of a decision. A-libs and removed A-docs labels Mar 31, 2015
@steveklabnik
Copy link
Member

Removing A-docs for now, as there's clearly some discussion that needs to happen before I document whatever is decided.

@Gankra
Copy link
Contributor

Gankra commented Mar 31, 2015

@steveklabnik I'll happily handle the documentation on this once a concensus is reached. Note that there's no real back-compat hazard here vis-a-vis failing to make a decision. This is a pure performance issue and the docs clearly leave it as implementation defined behaviour. (not sure what I-needs-decision is intended to imply)

@steveklabnik
Copy link
Member

(not sure what I-needs-decision is intended to imply)

Historically we've used it over the last few weeks as a 'we need to make some sort of call here,' so to not clog up the triage meeting. So feels about right: there's some quibble as to what's expected, and some decision needs to be made.

Manishearth added a commit to Manishearth/rust that referenced this issue Apr 1, 2015
…kler

This introduces no functional changes except for reducing a few unnecessary operations and variables.  Vec has the behavior that, if you request space past the capacity with reserve(), it will round up to the nearest power of 2.  What that effectively means is that after the first call to reserve(16), we are doubling our capacity every time.  So using the DEFAULT_BUF_SIZE and doubling cap_size() here is meaningless and has no effect on the call to reserve().

Note that with rust-lang#23842 implemented this will hopefully have a clearer API and less of a need for commenting.  If rust-lang#23842 is not implemented then the most clear implementation would be to call reserve_exact(buf.capacity()) at every step (and making sure that buf.capacity() is not zero at the beginning of the function of course).

Edit- functional change now introduced.  We will now zero 16 bytes of the vector first, then double to 32, then 64, etc. until we read 64kB.  This stops us from zeroing the entire vector when we double it, some of which may be wasted work.  Reallocation still follows the doubling strategy, but the responsibility has been moved to vec.extend(), which calls reserve() and push_back().
bors added a commit that referenced this issue Jul 17, 2015
Per the top level comment:

A low-level utility for more ergonomically allocating, reallocating, and deallocating a
a buffer of memory on the heap without having to worry about all the corner cases
involved. This type is excellent for building your own data structures like Vec and VecDeque.
In particular:

* Produces heap::EMPTY on zero-sized types
* Produces heap::EMPTY on zero-length allocations
* Catches all overflows in capacity computations (promotes them to "capacity overflow" panics)
* Guards against 32-bit systems allocating more than isize::MAX bytes
* Guards against overflowing your length
* Aborts on OOM
* Avoids freeing heap::EMPTY
* Contains a ptr::Unique and thus endows the user with all related benefits

This type does not in anyway inspect the memory that it manages. When dropped it *will*
free its memory, but it *won't* try to Drop its contents. It is up to the user of RawVec
to handle the actual things *stored* inside of a RawVec.

Note that a RawVec always forces its capacity to be usize::MAX for zero-sized types.
This enables you to use capacity growing logic catch the overflows in your length
that might occur with zero-sized types.

However this means that you need to be careful when roundtripping this type
with a `Box<[T]>`: `cap()` won't yield the len. However `with_capacity`,
`shrink_to_fit`, and `from_box` will actually set RawVec's private capacity
field. This allows zero-sized types to not be special-cased by consumers of
this type.

Edit: 
fixes #18726 and fixes #23842
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
I-needs-decision Issue: In need of a decision.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants