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 aVec<T>with extra steps. It is three fields — a shared storage, a raw pointer and a length — and it has nocapacity, noreserveand noresize. 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 notstd::sync::Arceither: it is a hand-rolledAtomicU64refcount with four different ownership modes, because Arrow buffers can be owned by aVec, 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:
/// [`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:
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:
/// 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:
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:
#[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.
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 (aBox<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:
// 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:
// 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:
// 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:
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:
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:
/// 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:
#[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:
/// 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:
/// 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:
/// 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:
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:
/// 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:
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,ExternalandLeakedin 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.