Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add freelists for PyComplex, PyInt, PyBoundMethod, PyRange
- PyComplex(100), PyInt(100), PyBoundMethod(20), PyRange(6)
- Use try_with instead of with in all freelist push/pop to prevent
  panic on thread-local access during thread teardown
  • Loading branch information
youknowone committed Mar 4, 2026
commit d3cc53acd352ebaadd19a9079ac4738771b62c8d
40 changes: 40 additions & 0 deletions crates/vm/src/builtins/complex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use crate::{
stdlib::warnings,
types::{AsNumber, Comparable, Constructor, Hashable, PyComparisonOp, Representable},
};
use core::cell::Cell;
use core::num::Wrapping;
use core::ptr::NonNull;
use num_complex::Complex64;
use num_traits::Zero;
use rustpython_common::hash;
Expand All @@ -24,11 +26,49 @@ pub struct PyComplex {
value: Complex64,
}

// spell-checker:ignore MAXFREELIST
thread_local! {
static COMPLEX_FREELIST: Cell<crate::object::FreeList<PyComplex>> = const { Cell::new(crate::object::FreeList::new()) };
}

impl PyPayload for PyComplex {
const MAX_FREELIST: usize = 100;
const HAS_FREELIST: bool = true;

#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.complex_type
}

#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
COMPLEX_FREELIST
.try_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
})
.unwrap_or(false)
}

#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
COMPLEX_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

impl ToPyObject for Complex64 {
Expand Down
39 changes: 22 additions & 17 deletions crates/vm/src/builtins/dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,27 +77,32 @@ impl PyPayload for PyDict {

#[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
})
DICT_FREELIST
.try_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
})
.unwrap_or(false)
}

#[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
})
DICT_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

Expand Down
39 changes: 22 additions & 17 deletions crates/vm/src/builtins/float.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,32 @@ impl PyPayload for PyFloat {

#[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
})
FLOAT_FREELIST
.try_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
})
.unwrap_or(false)
}

#[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
})
FLOAT_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

Expand Down
40 changes: 40 additions & 0 deletions crates/vm/src/builtins/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use crate::{
Callable, Comparable, Constructor, GetAttr, GetDescriptor, PyComparisonOp, Representable,
},
};
use core::cell::Cell;
use core::ptr::NonNull;
use core::sync::atomic::{AtomicU32, Ordering::Relaxed};
use itertools::Itertools;
#[cfg(feature = "jit")]
Expand Down Expand Up @@ -1205,11 +1207,49 @@ impl PyBoundMethod {
}
}

// spell-checker:ignore MAXFREELIST
thread_local! {
static BOUND_METHOD_FREELIST: Cell<crate::object::FreeList<PyBoundMethod>> = const { Cell::new(crate::object::FreeList::new()) };
}

impl PyPayload for PyBoundMethod {
const MAX_FREELIST: usize = 20;
const HAS_FREELIST: bool = true;

#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.bound_method_type
}

#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
BOUND_METHOD_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop bound-method payload refs before freelist caching

Pushing PyBoundMethod objects into the freelist here keeps their object and function PyObjectRefs alive until that freelist slot is reused (or thread teardown), because PyBoundMethod does not provide a payload clear step that releases those references before list.push(obj). In practice, creating and dropping bound methods can now delay destruction/finalizers of arbitrary user objects and retain resources unexpectedly when the freelist is not churned.

Useful? React with 👍 / 👎.

true
} else {
false
};
fl.set(list);
stored
})
.unwrap_or(false)
}

#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
BOUND_METHOD_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== PyBoundMethod freelist + traverse declarations =="
rg -n "name = \"method\", traverse|unsafe impl Traverse for PyBoundMethod|BOUND_METHOD_FREELIST" \
  crates/vm/src/builtins/function.rs -C4

echo
echo "== Traverse clear implementations for object refs =="
rg -n "impl .*Traverse for PyObjectRef|impl .*Traverse for Option<PyObjectRef>|fn clear\\(" \
  crates/vm/src -g '*.rs' -C4

echo
echo "== Dealloc/freelist flow in object core =="
rg -n "tp_clear|freelist_push|drop\\(edges\\)|freelist_pop|drop_in_place" \
  crates/vm/src/object/core.rs -C5

Repository: RustPython/RustPython

Length of output: 16552


Implement explicit Traverse::clear for PyBoundMethod to detach refs before freelist storage.

PyBoundMethod uses auto traverse (line 1066) but lacks a manual clear() implementation. The default clear() does nothing, so the object and function strong references remain embedded in freelisted bound methods. This blocks garbage collection of referenced objects.

The fix is straightforward: implement manual Traverse with an explicit clear() that detaches both fields, matching the pattern already established for PyFunction and other ref-bearing types (PyTuple, PyList, PyDict, etc.).

Proposed fix
-#[pyclass(module = false, name = "method", traverse)]
+#[pyclass(module = false, name = "method", traverse = "manual")]
 #[derive(Debug)]
 pub struct PyBoundMethod {
     object: PyObjectRef,
     function: PyObjectRef,
 }
+
+unsafe impl Traverse for PyBoundMethod {
+    fn traverse(&self, traverse_fn: &mut TraverseFn<'_>) {
+        self.object.traverse(traverse_fn);
+        self.function.traverse(traverse_fn);
+    }
+
+    fn clear(&mut self, out: &mut Vec<PyObjectRef>) {
+        out.push(core::mem::replace(
+            &mut self.object,
+            Context::genesis().none.to_owned().into(),
+        ));
+        out.push(core::mem::replace(
+            &mut self.function,
+            Context::genesis().none.to_owned().into(),
+        ));
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/vm/src/builtins/function.rs` around lines 1179 - 1222, PyBoundMethod
currently uses the auto-derived Traverse but lacks a manual Traverse::clear
implementation, so freelisted PyBoundMethod objects retain strong refs to their
object and function fields and block GC; implement a manual impl Traverse for
PyBoundMethod that provides a clear(&self) (or clear(&mut self) following the
crate pattern) which detaches/releases the strong references (set object and
function fields to PyObjectRef::none()/ptr::null or equivalent detach method
used by PyFunction/PyTuple), then ensure freelist_push stores only cleared
objects so freelisted PyBoundMethod will not keep references alive (update the
impl for PyPayload::clear/Traverse for PyBoundMethod to match the existing
pattern used by PyFunction, PyTuple, PyList, PyDict).


impl Representable for PyBoundMethod {
Expand Down
40 changes: 40 additions & 0 deletions crates/vm/src/builtins/int.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ use crate::{
types::{AsNumber, Comparable, Constructor, Hashable, PyComparisonOp, Representable},
};
use alloc::fmt;
use core::cell::Cell;
use core::ops::{Neg, Not};
use core::ptr::NonNull;
use malachite_bigint::{BigInt, Sign};
use num_integer::Integer;
use num_traits::{One, Pow, PrimInt, Signed, ToPrimitive, Zero};
Expand Down Expand Up @@ -48,7 +50,15 @@ where
}
}

// spell-checker:ignore MAXFREELIST
thread_local! {
static INT_FREELIST: Cell<crate::object::FreeList<PyInt>> = const { Cell::new(crate::object::FreeList::new()) };
}

impl PyPayload for PyInt {
const MAX_FREELIST: usize = 100;
const HAS_FREELIST: bool = true;

#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.int_type
Expand All @@ -57,6 +67,36 @@ impl PyPayload for PyInt {
fn into_pyobject(self, vm: &VirtualMachine) -> PyObjectRef {
vm.ctx.new_int(self.value).into()
}

#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
INT_FREELIST
.try_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
})
.unwrap_or(false)
}

#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
INT_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

macro_rules! impl_into_pyobject_int {
Expand Down
39 changes: 22 additions & 17 deletions crates/vm/src/builtins/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,27 +89,32 @@ impl PyPayload for PyList {

#[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
})
LIST_FREELIST
.try_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
})
.unwrap_or(false)
}

#[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
})
LIST_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

Expand Down
40 changes: 40 additions & 0 deletions crates/vm/src/builtins/range.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use crate::{
Representable, SelfIter,
},
};
use core::cell::Cell;
use core::cmp::max;
use core::ptr::NonNull;
use crossbeam_utils::atomic::AtomicCell;
use malachite_bigint::{BigInt, Sign};
use num_integer::Integer;
Expand Down Expand Up @@ -67,11 +69,49 @@ pub struct PyRange {
pub step: PyIntRef,
}

// spell-checker:ignore MAXFREELIST
thread_local! {
static RANGE_FREELIST: Cell<crate::object::FreeList<PyRange>> = const { Cell::new(crate::object::FreeList::new()) };
}

impl PyPayload for PyRange {
const MAX_FREELIST: usize = 6;
const HAS_FREELIST: bool = true;

#[inline]
fn class(ctx: &Context) -> &'static Py<PyType> {
ctx.types.range_type
}

#[inline]
unsafe fn freelist_push(obj: *mut PyObject) -> bool {
RANGE_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let stored = if list.len() < Self::MAX_FREELIST {
list.push(obj);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear range endpoints before caching range objects

PyRange now enters a freelist, but each cached object still owns start/stop/step PyIntRefs because there is no clear path before list.push(obj). This means dropped ranges can retain large bigint endpoint allocations until the freelist slot is reused (or thread exit), creating avoidable memory retention spikes in workloads that create large ranges.

Useful? React with 👍 / 👎.

true
} else {
false
};
fl.set(list);
stored
})
.unwrap_or(false)
}

#[inline]
unsafe fn freelist_pop() -> Option<NonNull<PyObject>> {
RANGE_FREELIST
.try_with(|fl| {
let mut list = fl.take();
let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) });
fl.set(list);
result
})
.ok()
.flatten()
}
}

impl PyRange {
Expand Down
Loading