Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
56ff758
add `PyMemRawAllocator`
chirizxc Jul 31, 2026
27eab03
docs
chirizxc Jul 31, 2026
6c554a0
docs (x2)
chirizxc Jul 31, 2026
781b2c3
add newsfragments
chirizxc Jul 31, 2026
eadec33
update MAX_ALIGN
chirizxc Jul 31, 2026
707c873
update newsfragments
chirizxc Jul 31, 2026
0ae37cc
fix comment
chirizxc Jul 31, 2026
c430046
update doc
chirizxc Jul 31, 2026
d5dc676
refactor
chirizxc Jul 31, 2026
56dce48
remove unnecessary `unsafe` block
chirizxc Jul 31, 2026
2f1bdba
refactoring
chirizxc Jul 31, 2026
8a1777c
fmt
chirizxc Jul 31, 2026
583d780
rumdl fmt guide/src/features.md
chirizxc Jul 31, 2026
61f76db
fix newsfragments
chirizxc Jul 31, 2026
e3806cb
fix
chirizxc Jul 31, 2026
7ec0df4
try fix
chirizxc Jul 31, 2026
bcc71cb
add tests
chirizxc Jul 31, 2026
e388cec
remove whitespaces
chirizxc Jul 31, 2026
d53be71
more test
chirizxc Jul 31, 2026
24e5953
fix cfg
chirizxc Jul 31, 2026
ddf34d8
GLOBAL -> GLOBAL_ALLOCATOR
chirizxc Jul 31, 2026
81ed81c
remove feature
chirizxc Jul 31, 2026
0aa7a07
fix
chirizxc Jul 31, 2026
429bb8a
add safety comments
chirizxc Jul 31, 2026
1c9b8a3
update safety comments
chirizxc Jul 31, 2026
e416c10
update safety comments, add cpython docs links
chirizxc Jul 31, 2026
6624118
sync `pymem.h` with 3.15 branch
chirizxc Jul 31, 2026
4cc1242
fix clippy
chirizxc Aug 1, 2026
4c5106e
fix `cargo doc`
chirizxc Aug 1, 2026
89b135c
fix
chirizxc Aug 1, 2026
16e9763
fix clippy
chirizxc Aug 1, 2026
cffcdf7
fix test
chirizxc Aug 1, 2026
d9f21b8
fmt
chirizxc Aug 1, 2026
f8dd183
fix clippy
chirizxc Aug 1, 2026
ea4c350
fix MSRV
chirizxc Aug 1, 2026
e56c4a1
fix MSRV (x2)
chirizxc Aug 1, 2026
db5e086
fix MSRV (x3)
chirizxc Aug 1, 2026
5068b35
fix clippy
chirizxc Aug 1, 2026
eec4e73
Merge branch 'main' into pymem-raw-alloc
chirizxc Aug 7, 2026
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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ chrono-local = ["chrono/clock", "dep:iana-time-zone"]
# Optimizes PyObject to Vec conversion and so on.
nightly = []

pymem-raw-alloc = []
Comment thread
chirizxc marked this conversation as resolved.
Outdated

# Activates all additional features
# This is mostly intended for testing purposes - activating *all* of these isn't particularly useful.
full = [
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,9 @@ pub mod inspect;
// other paths to the same items. (e.g. `pyo3::types::PyAnyMethods` instead of `pyo3::prelude::PyAnyMethods`).
pub mod prelude;

#[cfg(feature = "pymem-raw-alloc")]
pub mod pymem_alloc;

/// Test readme and user guide
#[cfg(doctest)]
pub mod doc_test {
Expand Down
126 changes: 126 additions & 0 deletions src/pymem_alloc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// TODO https://github.com/PyO3/pyo3/issues/5487
#![allow(clippy::undocumented_unsafe_blocks)]

//! GlobalAlloc backed by CPython's `PyMem_Raw*` (`PYMEM_DOMAIN_RAW`).
//!
//! ```
//! use pyo3::pymem_alloc::PyMemRawAllocator;
//!
//! #[global_allocator]
//! static GLOBAL_ALLOCATOR: PyMemRawAllocator = PyMemRawAllocator;
//! ```

use core::{
alloc::{GlobalAlloc, Layout},
mem::size_of,
ptr,
};

/// `GlobalAlloc` implementation backed by CPython's `PyMem_Raw*` functions
/// (`PYMEM_DOMAIN_RAW`). Safe to use from any thread, attached or not,
/// since the raw domain doesn't require an attached thread state.
pub struct PyMemRawAllocator;
Comment on lines +17 to +20

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

 /// `GlobalAlloc` implementation backed by CPython's `PyMem_Raw*` functions (`PYMEM_DOMAIN_RAW`).
-/// Safe to use from any thread, attached or not, since the raw domain doesn't require an attached
-/// thread state.
+///
+/// The raw domain does not require an attached thread state.
+///
+/// If `tracemalloc` is enabled, CPython replaces the raw allocator with hooks that call
+/// `PyGILState_Ensure()` before recording a traceback, then walk the current thread's Python
+/// frame stack. Calling this allocator (`alloc`/`alloc_zeroed`/`realloc`, not `dealloc`) from a
+/// detached thread will therefore attach a thread state on demand while `tracemalloc` is tracing.
 pub struct PyMemRawAllocator;

@anuraaga anuraaga Aug 3, 2026

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.

Thanks this looks good but maybe "attach to the thread" or "attach to Python" are more familiar to pyo3 developers then gilensure.

This is editorial but it means it could be worth pointing out that libraries that allocate frequently on non-Python threads may want to only use this allocator when PY_GIL_DISABLED.

@kumaraditya303 sorry to randomly cc but wonder if you have any insight here. I have been wondering about the implications of tracemalloc on the scientific python changes. I don't think they guard on free-threaded only IIUC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Looking at Python/tracemalloc.c, the PYMEM_DOMAIN_RAW hooks appear to be unconditional -> no Py_GIL_DISABLED guard:

So it looks like raw-domain allocations attach a thread state on demand whenever tracemalloc is tracing, in both GIL and free-threaded builds, I don't see it guarded to free-threaded only 🧐

If scientific-Python extensions allocate frequently from detached/non-Python threads, enabling tracemalloc would introduce this attach cost (and the associated reentrancy/deadlock surface handled by get_reentrant()/set_reentrant()) unconditionally, not just under Py_GIL_DISABLED.

Would appreciate a correction if there's a path I'm missing

@anuraaga anuraaga Aug 3, 2026

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.

Sorry yeah - the reason I suggest free-threaded vs not is only because attach while costly doesn't block or potentically deadlock in most cases on free-threaded, so the risk of need_gil is much lower. So my understanding is on free-threaded, it can introduce large overhead - but tracemalloc by default introduces large overhead anyways. On GIL, it can have actual threading/scaling issues, code that should be parallel becomes serial and in some cases freeze (I think).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sorry to randomly cc but wonder if you have any insight here. I have been wondering about the implications of tracemalloc on the scientific python changes. I don't think they guard on free-threaded only IIUC

The behaviour of tracemalloc is same on gil enabled and free-threaded builds. In terms of performance it is a little worse on free-threading because of contention on lock but that is unavoidable. In future I expect tracemalloc to be improved but in the mean time it has similar performance as on gil enabled builds.

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.

I am wondering mostly about non-python threads, for example I think numpy uses PyMem_RawAlloc now on non-python compute threads. Doesn't tracemalloc cause the threads to serialize on allocations?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am wondering mostly about non-python threads, for example I think numpy uses PyMem_RawAlloc now on non-python compute threads. Doesn't tracemalloc cause the threads to serialize on allocations?

Yes, it does but only when tracemalloc is enabled. In 3.15 I added a fast path to skip locking if tracemalloc is disabled which is the general case.


// CPython documents this alignment as `ALIGNOF_MAX_ALIGN_T`
// (8 on Windows; autoconf-derived on Unix, not guaranteed >8 on every target/libc).
const MAX_ALIGN: usize = 8;
Comment thread
chirizxc marked this conversation as resolved.
Outdated

/// Bytes reserved before an over-aligned block to stash the original
/// pointer returned by `PyMem_RawMalloc`, so it can be recovered for
/// `PyMem_RawFree` / `PyMem_RawRealloc`.
const HEADER: usize = size_of::<*mut u8>();

#[cold]
unsafe fn raw_alloc_aligned_with_header(layout: Layout) -> *mut u8 {
let Some(total) = layout
.size()
.checked_add(layout.align())
.and_then(|total| total.checked_add(HEADER))
else {
return ptr::null_mut();
};

let raw = unsafe { pyo3_ffi::PyMem_RawMalloc(total) } as *mut u8;

if raw.is_null() {
return ptr::null_mut();
}

unsafe { finish_aligned(raw, layout) }
}

#[cold]
unsafe fn raw_calloc_aligned_with_header(layout: Layout) -> *mut u8 {
let Some(total) = layout
.size()
.checked_add(layout.align())
.and_then(|total| total.checked_add(HEADER))
else {
return ptr::null_mut();
};
let raw = unsafe { pyo3_ffi::PyMem_RawCalloc(1, total) } as *mut u8;

if raw.is_null() {
return ptr::null_mut();
}
unsafe { finish_aligned(raw, layout) }
}

#[inline]
unsafe fn finish_aligned(raw: *mut u8, layout: Layout) -> *mut u8 {
let addr = raw as usize + HEADER;
let aligned_addr = (addr + layout.align() - 1) & !(layout.align() - 1);
let block = unsafe { raw.add(aligned_addr - raw as usize) };
unsafe { (block.sub(HEADER) as *mut *mut u8).write_unaligned(raw) };

block
}

#[inline]
unsafe fn recover_raw(ptr: *mut u8) -> *mut u8 {
unsafe { (ptr.sub(HEADER) as *mut *mut u8).read_unaligned() }
}

unsafe impl GlobalAlloc for PyMemRawAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if layout.align() <= MAX_ALIGN {
unsafe { pyo3_ffi::PyMem_RawMalloc(layout.size()) as *mut u8 }
} else {
unsafe { raw_alloc_aligned_with_header(layout) }
}
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if layout.align() <= MAX_ALIGN {
unsafe { pyo3_ffi::PyMem_RawFree(ptr as *mut _) }
} else {
let raw = unsafe { recover_raw(ptr) };
unsafe { pyo3_ffi::PyMem_RawFree(raw as *mut _) }
}
}

unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
if layout.align() <= MAX_ALIGN {
unsafe { pyo3_ffi::PyMem_RawCalloc(1, layout.size()) as *mut u8 }
} else {
unsafe { raw_calloc_aligned_with_header(layout) }
}
}

unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let new_layout = unsafe { Layout::from_size_align_unchecked(new_size, layout.align()) };

if layout.align() <= MAX_ALIGN && new_layout.align() <= MAX_ALIGN {
return unsafe { pyo3_ffi::PyMem_RawRealloc(ptr as *mut _, new_size) as *mut u8 };
}

let new_ptr = unsafe { self.alloc(new_layout) };
if !new_ptr.is_null() {
unsafe {
ptr::copy_nonoverlapping(ptr, new_ptr, layout.size().min(new_size));
self.dealloc(ptr, layout);
}
}
new_ptr
}
}
Loading