freelist optimization (#7337)

* Add object freelist infrastructure and PyFloat freelist

Add freelist_push/freelist_pop hooks to PyPayload trait with default
no-op implementations. Modify default_dealloc to offer dead objects
to the freelist before freeing memory. Modify PyRef::new_ref to try
popping from the freelist before allocating.

Implement thread-local freelist for PyFloat (max 100 entries).
Float objects are ideal candidates: fixed-size payload (f64),
no GC tracking, no instance dict, trivial Drop.

Benchmark improvement on float-heavy workloads:
  float_arith: ~12% faster
  float_list:  ~17% faster

* Add PyList freelist and GC-safe freelist infrastructure

Add HAS_FREELIST const to PyPayload trait. For freelist types, GC
untracking is done immediately (not deferred) during dealloc to
prevent race conditions when the object is reused.

Restructure new_ref so freelist-popped objects also get GC tracked,
enabling freelist support for GC-tracked types like PyList.

Add drop_in_place for old payload before writing new one, required
for types with non-trivial Drop (e.g. PyList's RwLock<Vec>).

Implement thread-local freelist for PyList (max 80 entries).

* Add PyDict freelist and exact-type guard for freelist push

Add thread-local freelist for PyDict (max 80 entries).

Add heaptype check in default_dealloc to prevent subclass instances
from entering the freelist. Subclass objects have a different Python
type than the base type, so reusing them would return objects with
the wrong __class__.

Skip PyTuple freelist: structseq types (stat_result, struct_time)
are static subtypes sharing the same Rust payload, making type-safe
reuse impractical with heaptype check alone.

* Add PySlice freelist (max 1, matching PySlice_MAXFREELIST)

* Add FreeList<T> wrapper to drain cached objects on thread teardown

Replace Cell<Vec<*mut PyObject>> with Cell<FreeList<T>> which implements
Drop to properly deallocate PyInner<T> when threads exit.
Also fix freelist_pop safety doc to match actual contract.

* Add manual Traverse impl for PySlice with clear() and fix comment

* Deduplicate allocation fallback in PyRef::new_ref
This commit is contained in:
Jeong, YunWon authored and GitHub committed 2026-03-04 20:00:18 +09:00
1 parent 9899deba82
commit af8f41ded1
8 files changed
+289 -12

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ finalizers
firsttraceable
flowgraph
formatfloat
freelist
freevar
freevars
fromlist
+34
View File
@@ -26,6 +26,8 @@ use crate::{
vm::VirtualMachine,
};
use alloc::fmt;
use core::cell::Cell;
use core::ptr::NonNull;
use rustpython_common::lock::PyMutex;
use rustpython_common::wtf8::Wtf8Buf;
@@ -60,11 +62,43 @@ impl fmt::Debug for PyDict {
}
}
thread_local! {
static DICT_FREELIST: Cell<crate::object::FreeList<PyDict>> = const { Cell::new(crate::object::FreeList::new()) };
}
impl PyPayload for PyDict {
const MAX_FREELIST: usize = 80;
const HAS_FREELIST: bool = true;
#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.dict_type
}
#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
DICT_FREELIST.with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
true
} else {
false
};
fl.set(list);
stored
})
}
#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
DICT_FREELIST.with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
}
}
impl PyDict {
+34
View File
@@ -15,6 +15,8 @@ use crate::{
protocol::PyNumberMethods,
types::{AsNumber, Callable, Comparable, Constructor, Hashable, PyComparisonOp, Representable},
};
use core::cell::Cell;
use core::ptr::NonNull;
use malachite_bigint::{BigInt, ToBigInt};
use num_complex::Complex64;
use num_traits::{Signed, ToPrimitive, Zero};
@@ -32,11 +34,43 @@ impl PyFloat {
}
}
thread_local! {
static FLOAT_FREELIST: Cell<crate::object::FreeList<PyFloat>> = const { Cell::new(crate::object::FreeList::new()) };
}
impl PyPayload for PyFloat {
const MAX_FREELIST: usize = 100;
const HAS_FREELIST: bool = true;
#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.float_type
}
#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
FLOAT_FREELIST.with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
true
} else {
false
};
fl.set(list);
stored
})
}
#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
FLOAT_FREELIST.with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
}
}
impl ToPyObject for f64 {
+34
View File
@@ -27,7 +27,9 @@ use crate::{
use rustpython_common::wtf8::Wtf8Buf;
use alloc::fmt;
use core::cell::Cell;
use core::ops::DerefMut;
use core::ptr::NonNull;
#[pyclass(module = false, name = "list", unhashable = true, traverse = "manual")]
#[derive(Default)]
@@ -72,11 +74,43 @@ unsafe impl Traverse for PyList {
}
}
thread_local! {
static LIST_FREELIST: Cell<crate::object::FreeList<PyList>> = const { Cell::new(crate::object::FreeList::new()) };
}
impl PyPayload for PyList {
const MAX_FREELIST: usize = 80;
const HAS_FREELIST: bool = true;
#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.list_type
}
#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
LIST_FREELIST.with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
true
} else {
false
};
fl.set(list);
stored
})
}
#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
LIST_FREELIST.with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
}
}
impl ToPyObject for Vec<PyObjectRef> {
+55 -1
View File
@@ -1,5 +1,7 @@
// sliceobject.{h,c} in CPython
// spell-checker:ignore sliceobject
use core::cell::Cell;
use core::ptr::NonNull;
use rustpython_common::wtf8::{Wtf8Buf, wtf8_concat};
use super::{PyGenericAlias, PyStrRef, PyTupleRef, PyType, PyTypeRef};
@@ -15,7 +17,7 @@ use crate::{
use malachite_bigint::{BigInt, ToBigInt};
use num_traits::{One, Signed, Zero};
#[pyclass(module = false, name = "slice", unhashable = true, traverse)]
#[pyclass(module = false, name = "slice", unhashable = true, traverse = "manual")]
#[derive(Debug)]
pub struct PySlice {
pub start: Option<PyObjectRef>,
@@ -23,11 +25,63 @@ pub struct PySlice {
pub step: Option<PyObjectRef>,
}
// SAFETY: Traverse properly visits all owned PyObjectRefs
unsafe impl crate::object::Traverse for PySlice {
fn traverse(&self, traverse_fn: &mut crate::object::TraverseFn<'_>) {
self.start.traverse(traverse_fn);
self.stop.traverse(traverse_fn);
self.step.traverse(traverse_fn);
}
fn clear(&mut self, out: &mut Vec<PyObjectRef>) {
if let Some(start) = self.start.take() {
out.push(start);
}
// stop is not Option, so it will be freed when payload is dropped
// (via drop_in_place on freelist pop, or Box::from_raw on dealloc)
if let Some(step) = self.step.take() {
out.push(step);
}
}
}
thread_local! {
static SLICE_FREELIST: Cell<crate::object::FreeList<PySlice>> = const { Cell::new(crate::object::FreeList::new()) };
}
impl PyPayload for PySlice {
const MAX_FREELIST: usize = 1;
const HAS_FREELIST: bool = true;
#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.slice_type
}
#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
SLICE_FREELIST.with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
true
} else {
false
};
fl.set(list);
stored
})
}
#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
SLICE_FREELIST.with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
}
}
#[pyclass(with(Comparable, Representable, Hashable))]
+3
View File
@@ -53,6 +53,9 @@ unsafe impl Traverse for PyTuple {
}
}
// No freelist for PyTuple: structseq types (stat_result, struct_time, etc.)
// are static subtypes sharing the same Rust payload, making type-safe reuse
// impractical without a type-pointer comparison at push time.
impl PyPayload for PyTuple {
#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
+95 -11
View File
@@ -171,13 +171,19 @@ pub(super) unsafe fn default_dealloc<T: PyPayload>(obj: *mut PyObject) {
// Untrack from GC BEFORE deallocation.
if obj_ref.is_gc_tracked() {
let ptr = unsafe { NonNull::new_unchecked(obj) };
rustpython_common::refcount::try_defer_drop(move || {
// untrack_object only removes the pointer address from a HashSet.
// It does NOT dereference the pointer, so it's safe even after deallocation.
unsafe {
crate::gc_state::gc_state().untrack_object(ptr);
}
});
if T::HAS_FREELIST {
// Freelist types must untrack immediately to avoid race conditions:
// a deferred untrack could remove a re-tracked entry after reuse.
unsafe { crate::gc_state::gc_state().untrack_object(ptr) };
} else {
rustpython_common::refcount::try_defer_drop(move || {
// untrack_object only removes the pointer address from a HashSet.
// It does NOT dereference the pointer, so it's safe even after deallocation.
unsafe {
crate::gc_state::gc_state().untrack_object(ptr);
}
});
}
}
// Extract child references before deallocation to break circular refs (tp_clear)
@@ -186,8 +192,17 @@ pub(super) unsafe fn default_dealloc<T: PyPayload>(obj: *mut PyObject) {
unsafe { clear_fn(obj, &mut edges) };
}
// Deallocate the object memory
drop(unsafe { Box::from_raw(obj as *mut PyInner<T>) });
// Try to store in freelist for reuse; otherwise deallocate.
// Only exact types (not heaptype subclasses) go into the freelist,
// because the pop site assumes the cached typ matches the base type.
let pushed = if T::HAS_FREELIST && obj_ref.class().heaptype_ext.is_none() {
unsafe { T::freelist_push(obj) }
} else {
false
};
if !pushed {
drop(unsafe { Box::from_raw(obj as *mut PyInner<T>) });
}
// Drop child references - may trigger recursive destruction.
// The object is already deallocated, so circular refs are broken.
@@ -807,6 +822,52 @@ impl<T: PyPayload + core::fmt::Debug> PyInner<T> {
}
}
/// Thread-local freelist storage that properly deallocates cached objects
/// on thread teardown.
///
/// Wraps a `Vec<*mut PyObject>` and implements `Drop` to convert each
/// raw pointer back into `Box<PyInner<T>>` for proper deallocation.
pub(crate) struct FreeList<T: PyPayload> {
items: Vec<*mut PyObject>,
_marker: core::marker::PhantomData<T>,
}
impl<T: PyPayload> FreeList<T> {
pub(crate) const fn new() -> Self {
Self {
items: Vec::new(),
_marker: core::marker::PhantomData,
}
}
}
impl<T: PyPayload> Default for FreeList<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: PyPayload> Drop for FreeList<T> {
fn drop(&mut self) {
for ptr in self.items.drain(..) {
drop(unsafe { Box::from_raw(ptr as *mut PyInner<T>) });
}
}
}
impl<T: PyPayload> core::ops::Deref for FreeList<T> {
type Target = Vec<*mut PyObject>;
fn deref(&self) -> &Self::Target {
&self.items
}
}
impl<T: PyPayload> core::ops::DerefMut for FreeList<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.items
}
}
/// The `PyObjectRef` is one of the most used types. It is a reference to a
/// python object. A single python object can have multiple references, and
/// this reference counting is accounted for by this type. Use the `.clone()`
@@ -1720,8 +1781,31 @@ impl<T: PyPayload + crate::object::MaybeTraverse + core::fmt::Debug> PyRef<T> {
pub fn new_ref(payload: T, typ: crate::builtins::PyTypeRef, dict: Option<PyDictRef>) -> Self {
let has_dict = dict.is_some();
let is_heaptype = typ.heaptype_ext.is_some();
let inner = Box::into_raw(PyInner::new(payload, typ, dict));
let ptr = unsafe { NonNull::new_unchecked(inner.cast::<Py<T>>()) };
// Try to reuse from freelist (exact type only, no dict, no heaptype)
let cached = if !has_dict && !is_heaptype {
unsafe { T::freelist_pop() }
} else {
None
};
let ptr = if let Some(cached) = cached {
let inner = cached.as_ptr() as *mut PyInner<T>;
unsafe {
core::ptr::write(&mut (*inner).ref_count, RefCount::new());
(*inner).gc_bits.store(0, Ordering::Relaxed);
core::ptr::drop_in_place(&mut (*inner).payload);
core::ptr::write(&mut (*inner).payload, payload);
// typ, vtable, slots are preserved; dict is None, weak_list was
// cleared by drop_slow_inner before freelist push
}
// Drop the caller's typ since the cached object already holds one
drop(typ);
unsafe { NonNull::new_unchecked(inner.cast::<Py<T>>()) }
} else {
let inner = Box::into_raw(PyInner::new(payload, typ, dict));
unsafe { NonNull::new_unchecked(inner.cast::<Py<T>>()) }
};
// Track object if:
// - HAS_TRAVERSE is true (Rust payload implements Traverse), OR
+33
View File
@@ -5,6 +5,7 @@ use crate::{
types::PyTypeFlags,
vm::{Context, VirtualMachine},
};
use core::ptr::NonNull;
cfg_if::cfg_if! {
if #[cfg(feature = "threading")] {
@@ -46,6 +47,38 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static {
fn class(ctx: &Context) -> &'static Py<PyType>;
/// Whether this type has a freelist. Types with freelists require
/// immediate (non-deferred) GC untracking during dealloc to prevent
/// race conditions when the object is reused.
const HAS_FREELIST: bool = false;
/// Maximum number of objects to keep in the freelist.
const MAX_FREELIST: usize = 0;
/// Try to push a dead object onto this type's freelist for reuse.
/// Returns true if the object was stored (caller must NOT free the memory).
///
/// # Safety
/// `obj` must be a valid pointer to a `PyInner<Self>` with refcount 0,
/// after `drop_slow_inner` and `tp_clear` have already run.
#[inline]
unsafe fn freelist_push(_obj: *mut PyObject) -> bool {
false
}
/// Try to pop a pre-allocated object from this type's freelist.
/// The returned pointer still has the old payload; the caller must
/// reinitialize `ref_count`, `gc_bits`, and `payload`.
///
/// # Safety
/// The returned pointer (if Some) must point to a valid `PyInner<Self>`
/// whose payload is still initialized from a previous allocation. The caller
/// will drop and overwrite `payload` before reuse.
#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
None
}
#[inline]
fn into_pyobject(self, vm: &VirtualMachine) -> PyObjectRef
where