Polars Buffers Are Immutable: The Refcounted Storage Behind Every Column

Polars' Buffer has no capacity, no reserve and no resize: it is a pointer, a length and an AtomicU64 refcount, sliced in O(1) and never grown in place.

TL;DR: polars_buffer::Buffer<T> is not a Vec<T> with extra steps. It is three fields — a shared storage, a raw pointer and a length — and it has no capacity, no reserve and no resize. A buffer is frozen the moment it is created; growing it is somebody else’s job (the mutable builders), and every consumer that wants a different view of the same bytes gets one by bumping a pointer. The sharing is not std::sync::Arc either: it is a hand-rolled AtomicU64 refcount with four different ownership modes, because Arrow buffers can be owned by a Vec, by a foreign owner, by nobody, or by nobody at all.

I went looking for Polars’ buffer allocator expecting to find the usual performance folklore: a custom aligned allocator, 64-byte-aligned allocations for SIMD, amortized doubling inside the buffer type. None of that exists in crates/polars-buffer at this commit. What exists is more interesting, and it explains a lot of Polars’ behaviour downstream.

The premise of this article is the one-line summary that opens crates/polars-buffer/src/buffer.rs:

rust
/// [`Buffer`] is a contiguous memory region that can be shared across
/// thread boundaries.
///
/// The easiest way to think about [`Buffer<T>`] is being equivalent to
/// a `Arc<Vec<T>>`, with the following differences:
/// * slicing and cloning is `O(1)`.
/// * it supports external allocated memory
///
/// The easiest way to create one is to use its implementation of `From<Vec<T>>`.
///
/// # Examples
/// ```
/// use polars_buffer::Buffer;
///
/// let mut buffer: Buffer<u32> = vec![1, 2, 3].into();
/// assert_eq!(buffer.as_ref(), [1, 2, 3].as_ref());
///
/// // it supports copy-on-write semantics (i.e. back to a `Vec`)
/// let vec: Vec<u32> = buffer.into_mut().right().unwrap();
/// assert_eq!(vec, vec![1, 2, 3]);
///
/// // cloning and slicing is `O(1)` (data is shared)
/// let mut buffer: Buffer<u32> = vec![1, 2, 3].into();
/// let mut sliced = buffer.clone();
/// sliced.slice(1, 1);
/// assert_eq!(sliced.as_ref(), [2].as_ref());
/// // but cloning forbids getting mut since `slice` and `buffer` now share data
/// assert_eq!(buffer.get_mut_slice(), None);
/// ```

crates/polars-buffer/src/buffer.rs:10

Two properties fall out of that doc comment, and they are the whole design: cloning is O(1), and mutability is conditional on being the only owner.

A buffer is a pointer, a length, and a refcount

The struct is deliberately minimal:

rust
pub struct Buffer<T> {
    /// The internal byte buffer.
    storage: SharedStorage<T>,

    /// A pointer into the buffer where our data starts.
    ptr: *const T,

    // The length of the buffer.
    length: usize,
}

crates/polars-buffer/src/buffer.rs:41

No capacity. No length-vs-capacity distinction. The pointer may be past the start of the allocation (that is what slicing does), and the storage knows the true extent of the allocation. That is why is_sliced is a comparison of two different quantities:

rust
    /// Returns whether underlying data is sliced.
    /// If sliced the [`Buffer`] is backed by
    /// more data than the length of `Self`.
    pub fn is_sliced(&self) -> bool {
        self.storage.len() != self.length
    }

crates/polars-buffer/src/buffer.rs:161

Cloning a Buffer is a refcount bump plus two copies of a pointer and a length:

rust
impl<T> Clone for Buffer<T> {
    #[inline(always)]
    fn clone(&self) -> Self {
        Self {
            storage: self.storage.clone(),
            ptr: self.ptr,
            length: self.length,
        }
    }
}

crates/polars-buffer/src/buffer.rs:54

Slicing is a pointer bump, not a copy:

rust
    #[inline]
    pub fn slice_in_place<R: RangeBounds<usize>>(&mut self, range: R) {
        unsafe {
            let Range { start, end } = check_range(range, ..self.len());
            self.ptr = self.ptr.add(start);
            self.length = end - start;
        }
    }

crates/polars-buffer/src/buffer.rs:218

This is the single most load-bearing decision in the whole buffer layer. A filter that keeps 3 rows out of a million does not copy three rows; it clones a 16-byte handle and moves a pointer. A take that gathers 1000 scattered rows copies only the view metadata for the 1000 surviving rows if it can, and otherwise rebuilds a buffer — but the unchanged chunks of the column are shared with the source, untouched.

Four ways a buffer can own its bytes

SharedStorage is a hand-rolled Arc. The inner struct is a raw heap allocation behind a NonNull, which means the refcount lives next to the data rather than next to the handle — one allocation for the bytes, one for the refcount, and the Buffer handle itself is trivially copyable.

rust
enum BackingStorage {
    Vec {
        original_capacity: usize, // Elements, not bytes.
        vtable: &'static VecVTable,
    },
    ForeignOwner(Box<dyn Any + Send + 'static>),

    /// Backed by some external method which we do not need to take care of,
    /// but we still should refcount and drop the SharedStorageInner.
    External,

    /// Both the backing storage and the SharedStorageInner are leaked, no
    /// refcounting is done. This technically should be a flag on
    /// SharedStorageInner instead of being here, but that would add 8 more
    /// bytes to SharedStorageInner, so here it is.
    Leaked,
}

struct SharedStorageInner<T> {
    ref_count: AtomicU64,
    ptr: *mut T,
    length_in_bytes: usize,
    backing: BackingStorage,
    // https://github.com/rust-lang/rfcs/blob/master/text/0769-sound-generic-drop.md#phantom-data
    phantom: PhantomData<T>,
}

crates/polars-buffer/src/storage.rs:40 and crates/polars-buffer/src/storage.rs:56

The four modes cover the real life of a buffer in a dataframe engine:

  • Vec — the normal case: Polars allocated it and must free it.
  • ForeignOwner — the bytes belong to someone else (a Box<dyn Any> that keeps them alive), e.g. an imported Arrow array.
  • External — the bytes are borrowed from a slice whose lifetime the caller guarantees.
  • Leaked — nobody owns it and nobody refcounts it.

The Vec variant is where the type system earns its keep. SharedStorage<T> is generic over T, but the allocation was made as a Vec<U> for some other U (that is how try_transmute reinterprets a u64 buffer as f64 bytes). So the drop path cannot call Vec::<T>::from_raw_parts; it calls through a vtable captured at allocation time:

rust
// Allows us to transmute between types while also keeping the original
// stats and drop method of the Vec around.
struct VecVTable {
    size: usize,
    align: usize,
    drop_buffer: unsafe fn(*mut (), usize),
}

impl VecVTable {
    const fn new<T>() -> Self {
        unsafe fn drop_buffer<T>(ptr: *mut (), cap: usize) {
            unsafe { drop(Vec::from_raw_parts(ptr.cast::<T>(), 0, cap)) }
        }

        Self {
            size: size_of::<T>(),
            align: align_of::<T>(),
            drop_buffer: drop_buffer::<T>,
        }
    }

crates/polars-buffer/src/storage.rs:22

The vtable also records the size and alignment of the original element type, which is what makes “give me my Vec back” a checked operation rather than UB:

rust
            // We may only go back to a Vec if we originally came from a Vec
            // where the desired size/align matches the original.
            let BackingStorage::Vec {
                original_capacity,
                vtable,
            } = &mut inner.backing
            else {
                return None;
            };

            if vtable.size != size_of::<T>() || vtable.align != align_of::<T>() {
                return None;
            }

crates/polars-buffer/src/storage.rs:384

This is where the alignment story actually lives: not in the allocator, but in the type of the element. A Buffer<u8> and a Buffer<f64> have the same size in bytes but different alignments, and the code refuses to transmute between them unless the target’s alignment divides the source’s:

rust
        // The pointer must be properly aligned for U.
        // We can skip this check if the alignment of U divides the alignment of T.
        if !align_of::<T>().is_multiple_of(align_of::<U>()) && !inner.ptr.cast::<U>().is_aligned() {
            return Err(self);
        }

crates/polars-buffer/src/storage.rs:438

The refcount, copied from Arc<T>

The atomic operations are literally Arc’s, with the comments to prove it:

rust
impl<T> Clone for SharedStorage<T> {
    fn clone(&self) -> Self {
        let inner = self.inner();
        if !matches!(inner.backing, BackingStorage::Leaked) {
            // Ordering semantics copied from Arc<T>.
            inner.ref_count.fetch_add(1, Ordering::Relaxed);
        }
        Self {
            inner: self.inner,
            phantom: PhantomData,
        }
    }
}

impl<T> Drop for SharedStorage<T> {
    fn drop(&mut self) {
        let inner = self.inner();
        if matches!(inner.backing, BackingStorage::Leaked) {
            return;
        }

        // Ordering semantics copied from Arc<T>.
        if inner.ref_count.fetch_sub(1, Ordering::Release) == 1 {
            std::sync::atomic::fence(Ordering::Acquire);
            unsafe {
                self.drop_slow();
            }
        }
    }
}

crates/polars-buffer/src/storage.rs:491 and crates/polars-buffer/src/storage.rs:483

Two details are worth pausing on. First, Leaked storage skips the atomic entirely — the global zero pool (§ below) pays no atomic traffic at all. Second, the Release/Acquire fence pair is the standard “publish my writes before the last owner drops the data” pattern: without it, the final Drop of the elements could race with a still-running reader.

The Drop of the inner storage is where the actual bytes are released:

rust
impl<T> Drop for SharedStorageInner<T> {
    fn drop(&mut self) {
        match core::mem::replace(&mut self.backing, BackingStorage::External) {
            BackingStorage::ForeignOwner(o) => drop(o),
            BackingStorage::Vec {
                original_capacity,
                vtable,
            } => unsafe {
                // Drop the elements in our slice.
                if std::mem::needs_drop::<T>() {
                    core::ptr::drop_in_place(core::ptr::slice_from_raw_parts_mut(
                        self.ptr,
                        self.length_in_bytes / size_of::<T>(),
                    ));
                }

                // Free the buffer.
                if original_capacity > 0 {
                    (vtable.drop_buffer)(self.ptr.cast(), original_capacity);
                }
            },
            BackingStorage::External | BackingStorage::Leaked => {},
        }
    }
}

crates/polars-buffer/src/storage.rs:88

Note original_capacity, not length_in_bytes: the deallocation has to hand back exactly the capacity the allocator gave us, and Vec::from_raw_parts with a mismatched capacity is undefined behaviour. That is why the capacity is captured at allocation time, and why the whole VecVTable dance exists in the first place.

Growth is somebody else’s job

Because Buffer has no reserve and no resize, something else has to grow. In the string path that someone is MutableBinaryViewArray, which owns a plain Vec<u8> that it doubles:

rust
    /// Get a [`View`] for a specific set of bytes.
    pub fn push_value_into_buffer(&mut self, bytes: &[u8]) -> View {
        assert!(bytes.len() <= u32::MAX as usize);

        if bytes.len() <= View::MAX_INLINE_SIZE as usize {
            View::new_inline(bytes)
        } else {
            self.total_buffer_len += bytes.len();

            // We want to make sure that we never have to memcopy between buffers. So if the
            // current buffer is not large enough, create a new buffer that is large enough and try
            // to anticipate the larger size.
            if self.in_progress_buffer.len().saturating_add(bytes.len())
                > usize::min(
                    BINVIEW_ARROW_BUFFER_LEN_LIMIT,
                    self.in_progress_buffer.capacity(),
                )
            {
                const {
                    assert!(MAX_EXP_BLOCK_SIZE < BINVIEW_ARROW_BUFFER_LEN_LIMIT);
                }

                // Allocate a new buffer and flush the old buffer.
                let new_capacity = usize::max(
                    bytes.len(),
                    (self.in_progress_buffer.capacity() * 2)
                        .clamp(DEFAULT_BLOCK_SIZE, MAX_EXP_BLOCK_SIZE),
                );

                let in_progress = Vec::with_capacity(new_capacity);
                let flushed = std::mem::replace(&mut self.in_progress_buffer, in_progress);
                if !flushed.is_empty() {
                    self.completed_buffers.push(flushed.into())
                }
            }

crates/polars-arrow/src/array/binview/mutable.rs:329

The doubling is clamped between DEFAULT_BLOCK_SIZE (8 KiB) and MAX_EXP_BLOCK_SIZE (16 MiB), so tiny columns do not allocate a huge buffer and enormous columns stop doubling. The same pattern appears in View::new_with_buffers_slow, with a RESIZE_LIMIT of 8192 bytes deciding whether to grow the last buffer or start a new one.

The architectural point: mutation happens in builders, which own Vecs; the finished Buffer is a frozen artefact. MutableBinaryViewArray::freeze converts the Vec<View> and Vec<Buffer<u8>> into Buffer<View> and Buffer<Buffer<u8>> and from then on the data is shared, not owned.

The one place Polars aligns memory on purpose

I promised to address the 64-byte claim, so let me be explicit: Polars does not align every Arrow buffer to 64 bytes, and there is no custom aligned allocator in polars-buffer. Buffers are ordinary Vec allocations, and their alignment is whatever the element type requires — 1 byte for u8, 4 for u32, 8 for f64.

There is exactly one over-aligned allocation in this layer, and it is for zeroed memory:

rust
#[repr(C, align(4096))]
#[derive(Copy, Clone)]
struct Aligned([u8; 4096]);

// We intentionally leak 8MiB of zeroed memory once so we don't have to
// refcount it.
const GLOBAL_ZERO_SIZE: usize = 8 * 1024 * 1024;
static GLOBAL_ZEROES: LazyLock<SharedStorage<Aligned>> = LazyLock::new(|| {
    const { assert!(GLOBAL_ZERO_SIZE.is_multiple_of(size_of::<Aligned>())) };
    let chunks = GLOBAL_ZERO_SIZE / size_of::<Aligned>();
    let v = vec![Aligned([0; _]); chunks];
    let mut ss = SharedStorage::from_vec(v);
    ss.leak();
    ss
});

crates/polars-buffer/src/buffer.rs:369

Buffer::zeroed then hands out slices of that single 8 MiB block, and the Leaked backing means no refcounting and no free — the block lives for the process lifetime. This is used for validity bitmaps and IPC padding, where “give me 1 MiB of zeros” should not mean “calloc 1 MiB and possibly touch every page”. The 4096-byte alignment of the chunk type means every handed-out slice is page-aligned, which is what you want if the consumer is going to mmap or hand the pages to something that expects page granularity.

There is a second, subtler alignment trick, in the empty storage:

rust
    /// Creates an empty SharedStorage.
    pub const fn empty() -> Self {
        assert!(align_of::<T>() <= 1 << 30);
        static INNER: SharedStorageInner<()> = SharedStorageInner {
            ref_count: AtomicU64::new(1),
            ptr: core::ptr::without_provenance_mut(1 << 30), // Very overaligned for any T.
            length_in_bytes: 0,
            backing: BackingStorage::Leaked,
            phantom: PhantomData,
        };

crates/polars-buffer/src/storage.rs:134

An empty buffer still needs a pointer that is valid for reads if anyone ever dereferences it, and the simplest such pointer is one that is absurdly aligned — 1 << 30 is aligned to every power of two up to a gigabyte, so align_of::<T>() is satisfied for any sane T without a per-type branch.

Where alignment actually shows up: the kernel side

Alignment does matter enormously for SIMD, but Polars handles it in the kernel layer rather than the allocator. The trick is a family of byte types that carry the alignment of the type they stand for:

rust
/// A representation of a type as raw bytes with the same alignment as the original type.
pub trait AlignedBytes: Pod + Default + Eq {
    const ALIGNMENT: usize;
    const SIZE: usize;
    const SIZE_ALIGNMENT_PAIR: PrimitiveSizeAlignmentPair;

    type Unaligned: AsRef<[u8]>
        + AsMut<[u8]>
        + std::ops::Index<usize, Output = u8>
        + std::ops::IndexMut<usize, Output = u8>
        + for<'a> TryFrom<&'a [u8]>
        + std::fmt::Debug
        + Default
        + IntoIterator<Item = u8>
        + Pod;

crates/polars-arrow/src/types/aligned_bytes.rs:11

and the macro that generates them:

rust
        /// Bytes with a size and alignment.
        ///
        /// This is used to reduce the monomorphizations for routines that solely rely on the size
        /// and alignment of types.
        #[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Pod, Zeroable)]
        #[repr(C, align($alignment))]
        pub struct $name([u8; $size]);

crates/polars-arrow/src/types/aligned_bytes.rs:43

The sizes and alignments that exist are the honest ones — 1, 2, 4, 8, 12, 16 and 32 bytes with alignments 1, 2, 4, 8 and 16:

rust
impl_aligned_bytes! {
    (Bytes1Alignment1, 1, 1, S1A1, [u8, i8], u8),
    (Bytes2Alignment2, 2, 2, S2A2, [u16, i16, pf16], u16),
    (Bytes4Alignment4, 4, 4, S4A4, [u32, i32, f32], u32),
    (Bytes8Alignment8, 8, 8, S8A8, [u64, i64, f64], u64),
    (Bytes8Alignment4, 8, 4, S8A4, [days_ms]),
    (Bytes12Alignment4, 12, 4, S12A4, [[u32; 3]]),
    (Bytes16Alignment4, 16, 4, S16A4, [View]),
    (Bytes16Alignment8, 16, 8, S16A8, [months_days_ns]),
    (Bytes16Alignment16, 16, 16, S16A16, [u128, i128]),
    (Bytes32Alignment16, 32, 16, S32A16, [i256]),
}

crates/polars-arrow/src/types/aligned_bytes.rs:144

The comment on Bytes16Alignment4 is the punchline: a View is 16 bytes wide but only 4-byte aligned, because it is four u32 fields. A kernel that wants to load 16 bytes of views as two u64s cannot assume 8-byte alignment, so the aligned wrapper tells the compiler the truth and the kernel does the (un)aligned load it has to do.

Getting the bytes back

Since the buffer is shared, the only way to mutate it is to be the sole owner. into_mut and get_mut_slice encode that:

rust
    /// Returns a mutable reference to its underlying [`Vec`], if possible.
    ///
    /// This operation returns [`Either::Right`] iff this [`Buffer`]:
    /// * has no alive clones
    /// * has not been imported from the C data interface (FFI)
    #[inline]
    pub fn into_mut(mut self) -> Either<Self, Vec<T>> {
        // We lose information if the data is sliced.
        if self.is_sliced() {
            return Either::Left(self);
        }
        match self.storage.try_into_vec() {
            Ok(v) => Either::Right(v),
            Err(slf) => {
                self.storage = slf;
                Either::Left(self)
            },
        }
    }

crates/polars-buffer/src/buffer.rs:296

Three separate reasons the answer can be “no”: the buffer is sliced (the storage holds more than the buffer exposes), there are other owners, or it came from the C data interface and Polars does not own the allocation. Note that the API never silently copies: it returns the buffer back unchanged and lets the caller decide.

The explicit escape hatch is to_vec, which is the one place a copy is guaranteed:

rust
impl<T: Clone> Buffer<T> {
    pub fn to_vec(self) -> Vec<T> {
        match self.into_mut() {
            Either::Right(v) => v,
            Either::Left(same) => same.as_slice().to_vec(),
        }
    }
}

crates/polars-buffer/src/buffer.rs:357

The bottom line

The buffer layer buys three things and pays for them in a specific way:

  • Cheap slicing and cheap cloning. A filter or a head/tail slice produces a new handle, not new bytes. Cost: the data cannot be mutated in place, so every mutating operation either needs exclusivity or must rebuild.
  • A single ownership model for four kinds of memory. Vec, ForeignOwner, External and Leaked in one enum, with a vtable to make the generic drop sound. Cost: 40-odd bytes of bookkeeping per allocation, and a vtable pointer chase on every deallocation.
  • Alignment handled where it matters. Element-type alignment is checked on every transmute; the only over-aligned allocation is the 8 MiB zero pool; SIMD alignment is a property of the kernel’s byte types, not of the allocator.

The trade-off to keep in mind when reading the rest of the codebase: if you see a Vec in a Polars kernel, it is because that kernel is building data. If you see a Buffer, it is data that is finished and shared. The boundary between the two is the freeze.

PolarsInternalsMemoryArrowSimd