Vec's Capacity Lives in a Niche: How Rust's RawVec Got One Word Smaller
Rust's RawVec stores its capacity in the high bit of a usize niche, so Vec is three words instead of four, and the growth policy is max(2*cap, required).
TL;DR:
Vec<T>is three machine words: pointer, length, capacity. It manages that by storing the capacity in a niche type — ausizewhose top bit is guaranteed to be zero — so the “zero capacity” case is representable without an extra discriminant. And the growth policy ismax(2 * cap, required), with a special minimum for tiny vectors.
The growth policy
// library/alloc/src/raw_vec/mod.rs:539-555
// Nothing we can really do about these checks, sadly.
let required_cap = len.checked_add(additional).ok_or(CapacityOverflow)?;
// This guarantees exponential growth. The doubling cannot overflow
// because `cap <= isize::MAX` and the type of `cap` is `usize`.
let cap = cmp::max(self.cap.as_inner() * 2, required_cap);
let cap = cmp::max(min_non_zero_cap(elem_layout.size()), cap);Three lines:
checked_addcatches the length overflow and turns it into aCapacityOverflowerror rather than a panic with a confusing message.max(2*cap, required)is the amortized-doubling rule. If the caller asked for a specific capacity, that wins; otherwise double.max(min_non_zero_cap(size), cap)imposes a floor.
The doubling cannot overflow because cap <= isize::MAX and the
multiplication is done in usize. That invariant is why the code can use
plain * instead of checked_mul.
The tiny-vector floor
// library/alloc/src/raw_vec/mod.rs:163-176
// Tiny Vecs are dumb. Skip to:
// - 8 if the element size is 1, because any heap allocator is likely
// to round up a request of less than 8 bytes to at least 8 bytes.
// - 4 if elements are moderate-sized (<= 1 KiB).
// - 1 otherwise, to avoid wasting too much space for very short Vecs.
const fn min_non_zero_cap(size: usize) -> usize {
if size == 1 {
8
} else if size <= 1024 {
4
} else {
1
}
}The reasoning is allocator-aware: malloc rounds a 1-byte request up to
a 16-byte chunk anyway, so allocating 1 byte and then reallocating to 8 is
pure waste. For medium-sized elements, 4 gives a reasonable first
allocation; for large elements, 1 avoids over-allocating.
The niche
// library/alloc/src/raw_vec/mod.rs:40-51
type Cap = core::num::niche_types::UsizeNoHighBit;
// SAFETY: 0 *definitely* is less than isize::MAX.
const ZERO_CAP: Cap = unsafe { Cap::new_unchecked(0) };
/// `Cap(cap)`, except if `T` is a ZST then `Cap::ZERO`.
///
/// # Safety: cap must be <= `isize::MAX`.
const unsafe fn new_cap<T>(cap: usize) -> Cap {
// SAFETY: Upheld by caller.
if T::IS_ZST { ZERO_CAP } else { unsafe { Cap::new_unchecked(cap) } }
}UsizeNoHighBit is a usize with an invariant: the top bit is always
zero. That is a niche — a value the type cannot represent — and Rust’s
niche optimization uses it to make Option<Cap> the same size as Cap.
Why does that matter? Because RawVec needs to represent “no allocation”
(distinguished by a null pointer) and a capacity. With the invariant, the
capacity’s top bit is free to be used as a flag… and, in fact, Cap::ZERO
is the zero-capacity case, which is also the ZST case.
The payoff: Vec<T> is { ptr, cap, len } — three words — instead of four.
For a Vec<u8> of 24 bytes on the stack, that is 8 bytes saved on every
Vec in the program, including the ones on the stack of every function.
into_raw_parts and from_raw_parts
// library/alloc/src/vec/mod.rs:795-801
#[must_use = "losing the pointer will leak memory"]
#[stable(feature = "vec_into_raw_parts", since = "1.93.0")]
#[rustc_const_unstable(feature = "const_heap", issue = "79507")]
pub const fn into_raw_parts(self) -> (*mut T, usize, usize) {
let mut me = ManuallyDrop::new(self);
(me.as_mut_ptr(), me.len(), me.capacity())
}ManuallyDrop suppresses the destructor so the buffer is not freed,
while the pointer/length/capacity are read out of the value. The
reconstruction side is the mirror image:
// library/alloc/src/vec/mod.rs:1127-1140
pub const unsafe fn from_raw_parts_in(
ptr: *mut T,
length: usize,
capacity: usize,
alloc: A,
) -> Self {
ub_checks::assert_unsafe_precondition!(
check_library_ub,
"Vec::from_raw_parts_in requires that length <= capacity",
(length: usize = length, capacity: usize = capacity) => length <= capacity
);
// SAFETY: Upheld by caller.
unsafe { Vec { buf: RawVec::from_raw_parts_in(ptr, capacity, alloc), len: length } }
}It is const, and the only validation is a debug-mode UB check. That
makes it a zero-cost FFI boundary primitive: Vec::into_raw_parts and
Vec::from_raw_parts round-trip through C without touching the allocator
at all.
drain is destructive but reversible
// library/alloc/src/vec/drain.rs:88-137
pub fn keep_rest(self) {
// At this moment layout looks like this:
//
// [head] [yielded by next] [unyielded] [yielded by next_back] [tail]
// ^-- start \_________/-- unyielded_len \____/-- self.tail_len
// ^-- unyielded_ptr ^-- tail
...
let mut this = ManuallyDrop::new(self);
unsafe {
let source_vec = this.vec.as_mut();
let start = source_vec.len();
let tail = this.tail_start;
let unyielded_len = this.iter.len();
let unyielded_ptr = this.iter.as_slice().as_ptr();
// ZSTs have no identity, so we don't need to move them around.
if !T::IS_ZST {
let start_ptr = source_vec.as_mut_ptr().add(start);
// memmove back unyielded elements
if unyielded_ptr != start_ptr {
ptr::copy(src, dst, unyielded_len);
}
// memmove back untouched tail
if tail != (start + unyielded_len) {
ptr::copy(src, dst, this.tail_len);
}
}
source_vec.set_len(start + unyielded_len + this.tail_len);
}
}The tail is restored with one memmove, not one move per element. And
the Drop implementation uses a nested guard struct so the memmove happens
even if the user’s Drop impl panics mid-drain:
// library/alloc/src/vec/drain.rs:176-200
impl<T, A: Allocator> Drop for Drain<'a, T, A> {
fn drop(&mut self) {
/// Moves back the un-`Drain`ed elements to restore the original `Vec`.
struct DropGuard<'r, 'a, T, A: Allocator>(&'r mut Drain<'a, T, A>);
impl<'r, 'a, T, A: Allocator> Drop for DropGuard<'r, 'a, T, A> {
fn drop(&mut self) {
if self.0.tail_len > 0 {
unsafe {
let source_vec = self.0.vec.as_mut();
// memmove back untouched tail, update to new length
let start = source_vec.len();
let tail = self.0.tail_start;
if tail != start {
let src = source_vec.as_ptr().add(tail);
let dst = source_vec.as_mut_ptr().add(start);
ptr::copy(src, dst, self.0.tail_len);
}
source_vec.set_len(start + self.0.tail_len);
}
}
}
}The naive alternative — Vec::remove in a loop — is O(n²) in moves.
Where it lives
library/alloc/src/raw_vec/mod.rs— the growth policy,min_non_zero_cap, theCapniche.library/alloc/src/vec/mod.rs—into_raw_parts,from_raw_parts_in.library/alloc/src/vec/drain.rs— theDropGuardtrick.library/core/src/num/niche_types.rs—UsizeNoHighBit.
Takeaway
The niche trick here is a nice illustration of a general Rust idiom: when a
type has a value it can never hold, that value is free. usize can never
be usize::MAX in a capacity, so the top bit is free, so Option<Cap> is
free, so Vec is one word smaller. Across a program with thousands of
live Vecs, that is megabytes of stack and heap.