Skip to content

[Core] Fix async actor crash on CPython 3.14 fiber-stack recursion guard - #63284

Closed
elliot-barn wants to merge 5 commits into
masterfrom
elliot-barn-raylet-314-updates
Closed

[Core] Fix async actor crash on CPython 3.14 fiber-stack recursion guard#63284
elliot-barn wants to merge 5 commits into
masterfrom
elliot-barn-raylet-314-updates

Conversation

@elliot-barn

Copy link
Copy Markdown
Collaborator

Prerequisite to #63237

On cp3.14, every Ray async actor (any @ray.remote class with an async def method) crashes the worker process before any user code runs:

Fatal Python error: _Py_CheckRecursiveCall: Unrecoverable stack overflow
Current thread 0x... [ray::<ActorName>] (most recent call first):
  <no Python frame>

cp3.14's _Py_ReachedRecursionLimit compares the current frame address to c_stack_soft_limit on _PyThreadStateImpl. That limit is anchored to the OS thread's pthread stack at PyThreadState bind time. Ray's async-actor dispatcher (FiberState) runs every task on a 256 KiB boost::fibers fixedsize_stack -- a memory region distinct from the pthread stack -- so the very first Python call inside the fiber trips the guard.

Fix: install a hook on FiberState that runs inside every fiber before the user callback. The hook rewrites c_stack_top / c_stack_soft_limit / c_stack_hard_limit to bracket the current fiber-stack frame with a 192 KiB budget out of the 256 KiB fiber stack. The hook uses opaque function-pointer plumbing in fiber.h (no Python.h dependency, so pure-C++ targets compile unchanged) and a cp3.14-gated implementation installed at module init from _raylet.pyx. On Python < 3.14 the installer compiles to a no-op stub and the hook stays null -- behavior is identical to upstream.

The hook leaks gilstate_counter by +1 once per fiber-runner OS thread so the PyThreadState we wrote to isn't torn down by PyGILState_Release at counter==0 (which would let the user callback's own PyGILState_Ensure allocate a fresh tstate and overwrite our c_stack_* via _Py_InitializeRecursionLimits). The GIL itself stays releasable through Ray's existing with nogil: block around YieldCurrentFiber, which calls PyEval_SaveThread directly and bypasses gilstate_counter -- so AsyncIO Thread can still acquire the GIL during awaits.

Verified by the hello_world_py314 release test and the new FiberStateTest.FiberPreCallbackFiresBeforeUserCallbackAndIsOptional gtest.

Thank you for contributing to Ray! 🚀
Please review the Ray Contribution Guide before opening a pull request.

⚠️ Remove these instructions before submitting your PR.

💡 Tip: Mark as draft if you want early feedback, or ready for review when it's complete.

Description

Briefly describe what this PR accomplishes and why it's needed.

Related issues

Link related issues: "Fixes #1234", "Closes #1234", or "Related to #1234".

Additional information

Optional: Add implementation details, API changes, usage examples, screenshots, etc.

On cp3.14, every Ray async actor (any @ray.remote class with an `async def`
method) crashes the worker process before any user code runs:

    Fatal Python error: _Py_CheckRecursiveCall: Unrecoverable stack overflow
    Current thread 0x... [ray::<ActorName>] (most recent call first):
      <no Python frame>

cp3.14's _Py_ReachedRecursionLimit compares the current frame address to
c_stack_soft_limit on _PyThreadStateImpl. That limit is anchored to the OS
thread's pthread stack at PyThreadState bind time. Ray's async-actor
dispatcher (FiberState) runs every task on a 256 KiB boost::fibers
fixedsize_stack -- a memory region distinct from the pthread stack -- so the
very first Python call inside the fiber trips the guard.

Fix: install a hook on FiberState that runs inside every fiber before the
user callback. The hook rewrites c_stack_top / c_stack_soft_limit /
c_stack_hard_limit to bracket the current fiber-stack frame with a 192 KiB
budget out of the 256 KiB fiber stack. The hook uses opaque function-pointer
plumbing in fiber.h (no Python.h dependency, so pure-C++ targets compile
unchanged) and a cp3.14-gated implementation installed at module init from
_raylet.pyx. On Python < 3.14 the installer compiles to a no-op stub and the
hook stays null -- behavior is identical to upstream.

The hook leaks gilstate_counter by +1 once per fiber-runner OS thread so the
PyThreadState we wrote to isn't torn down by PyGILState_Release at counter==0
(which would let the user callback's own PyGILState_Ensure allocate a fresh
tstate and overwrite our c_stack_* via _Py_InitializeRecursionLimits). The
GIL itself stays releasable through Ray's existing `with nogil:` block around
YieldCurrentFiber, which calls PyEval_SaveThread directly and bypasses
gilstate_counter -- so AsyncIO Thread can still acquire the GIL during awaits.

Verified by the hello_world_py314 release test and the new
FiberStateTest.FiberPreCallbackFiresBeforeUserCallbackAndIsOptional gtest.

Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
@elliot-barn
elliot-barn requested a review from a team as a code owner May 11, 2026 22:54

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a mechanism to re-anchor CPython 3.14 recursion limits when executing tasks on fibers, preventing fatal stack overflow errors caused by mismatched stack boundaries. The implementation adds a pre-callback hook to the FiberState class and a Cython-based handler that adjusts internal CPython thread state fields. Feedback highlights several critical areas for improvement: the use of non-portable GCC intrinsics that will break Windows builds, the fragility of hardcoded memory offsets for internal CPython structures, a potentially insufficient conditional check for re-anchoring, and a data race on the static callback member that should be addressed using atomics.

Comment thread python/ray/_raylet.pyx
PyThreadState *tstate = PyThreadState_Get();
uintptr_t *c_stack =
(uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t));
uintptr_t here = (uintptr_t)__builtin_frame_address(0);

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.

high

__builtin_frame_address is a GCC/Clang intrinsic and is not supported by the MSVC compiler used on Windows. This will break the Ray build on Windows for Python 3.14. Consider using a more portable way to obtain a stack address, such as taking the address of a local variable (e.g., uintptr_t here = (uintptr_t)&here;), or using an #ifdef block to provide an MSVC-compatible intrinsic like _AddressOfReturnAddress().

Comment thread python/ray/_raylet.pyx
PyGILState_STATE g = PyGILState_Ensure();
PyThreadState *tstate = PyThreadState_Get();
uintptr_t *c_stack =
(uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t));

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.

medium

The offset calculation for c_stack fields is extremely fragile as it relies on the internal memory layout of _PyThreadStateImpl in CPython 3.14. While the comment notes this, using sizeof(PyThreadState) is risky because PyThreadState is intended to be an opaque handle in the public API. If it becomes an incomplete type in future releases, this will fail to compile. Additionally, compiler-specific padding could invalidate these hardcoded offsets. A more robust approach would be to use a small C++ helper that includes the internal headers if Py_BUILD_CORE is defined, or at least add a static assertion to verify the expected layout.

Comment thread python/ray/_raylet.pyx
uintptr_t *c_stack =
(uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t));
uintptr_t here = (uintptr_t)__builtin_frame_address(0);
if (here <= c_stack[1] /* c_stack_soft_limit */) {

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.

medium

The conditional check if (here <= c_stack[1]) might be insufficient. If the fiber stack is allocated at a significantly higher memory address than the original pthread stack, here will be greater than c_stack_soft_limit, and the re-anchoring will be skipped. This would leave the Python interpreter with an incorrect (and likely much larger) recursion budget, potentially leading to a physical stack overflow of the 256 KiB fiber stack before the Python recursion guard trips. It is safer to always re-anchor the limits when entering a new fiber to ensure they match the fiber's actual stack boundaries.

private:
static constexpr size_t kStackSize = 1024 * 256;

inline static FiberPreCallback fiber_pre_callback_ = nullptr;

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.

medium

Accessing fiber_pre_callback_ from multiple threads (the thread calling SetFiberPreCallback and the fiber runner thread) without synchronization is a data race. Although it is typically set once during module initialization, the test suite resets it to nullptr at the end of tests, which could lead to undefined behavior if a fiber is still running or being dispatched. Consider making this member a std::atomic<FiberPreCallback> to ensure thread-safe access and proper memory visibility across threads. Note that this will require including <atomic>.

Comment thread src/ray/core_worker/task_execution/tests/fiber_state_test.cc
@elliot-barn elliot-barn added the alpha Alpha release features label May 12, 2026
@ray-gardener ray-gardener Bot added the core Issues that should be addressed in Ray Core label May 12, 2026
Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
Comment thread python/ray/_raylet.pyx
PyGILState_STATE g = PyGILState_Ensure();
PyThreadState *tstate = PyThreadState_Get();
uintptr_t *c_stack =
(uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong struct offset misses _PyInterpreterFrame base_frame field

High Severity

The offset calculation sizeof(PyThreadState) + sizeof(Py_ssize_t) to reach c_stack_top is incorrect. The actual CPython 3.14 _PyThreadStateImpl layout has _PyInterpreterFrame base_frame between PyThreadState base and Py_ssize_t refcount, but the code omits sizeof(_PyInterpreterFrame) from the offset. This causes c_stack to point into base_frame instead of the c_stack_* fields. Reads from c_stack[1] get a garbage value (not c_stack_soft_limit), and writes to c_stack[0..2] corrupt base_frame rather than updating the recursion limits, so the fix either silently corrupts memory or fails to re-anchor (leaving the original crash).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fac4017. Configure here.

@elliot-barn elliot-barn added go add ONLY when ready to merge, run all tests and removed alpha Alpha release features labels May 13, 2026
@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had
any activity for 14 days. It will be closed in another 14 days if no further activity occurs.
Thank you for your contributions.

You can always ask for help on our discussion forum or Ray's public slack channel.

If you'd like to keep this open, just leave any comment, and the stale label will be removed.

@github-actions github-actions Bot added the stale The issue is stale. It will be closed within 7 days unless there are further conversation label May 28, 2026
@edoakes edoakes added unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it. and removed stale The issue is stale. It will be closed within 7 days unless there are further conversation labels May 28, 2026
Comment thread src/ray/core_worker/task_execution/fiber.h
Comment thread python/ray/_raylet.pyx
c_stack[0] = here; // c_stack_top
c_stack[2] = here - budget; // c_stack_hard_limit
c_stack[1] = here - (budget - margin); // c_stack_soft_limit
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared stack limits across fibers

High Severity

On CPython 3.14 the fiber pre-hook rewrites one PyThreadState’s c_stack_* only when here <= c_stack_soft_limit, so shallow entry on later tasks often skips re-anchoring. It also runs once per task before the callback, not when a fiber resumes after YieldCurrentFiber. With async actors’ high default max_concurrency, overlapping fibers can share stale limits and hit fatal stack overflow again after awaits or on later tasks.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4054e97. Configure here.

/// extension at module init; pure-C++ targets keep it null. A plain C
/// function pointer keeps Python.h out of this header.
using FiberPreCallback = void (*)();
static void SetFiberPreCallback(FiberPreCallback cb) { fiber_pre_callback_ = cb; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing Doxygen on SetFiberPreCallback

Low Severity

The new static SetFiberPreCallback declaration is not documented with a Doxygen block that includes @param for its callback argument, as required for new C++ API in this project.

Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules

Reviewed by Cursor Bugbot for commit 4054e97. Configure here.

@AlexPoone

AlexPoone commented Jul 6, 2026

Copy link
Copy Markdown

I tried this on CPU with 12 threads, this actually makes the GIL lock all the other threads other than the main after loading neural networks into the RAM

Bring the cp3.14 async-actor fiber-stack fix up to date with master.

The core fix (fiber.h, _raylet.pyx) merged cleanly; master's unrelated
Event -> FiberEvent/StdEvent refactor in fiber.h merged in alongside it.
The only conflict was in fiber_state_test.cc, where master and this
branch each appended new tests at the same location; resolved by keeping
all of them (FiberPreCallbackFiresBeforeUserCallbackAndIsOptional plus
master's DrainsInFlightFibersBeforeStopping and DestructorStopsAndJoins).

Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit de17005. Configure here.

Comment thread python/ray/_raylet.pyx
} else {
gil_leaked = true;
(void)g;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

First hook leaves GIL held

High Severity

On each fiber-runner OS thread, the first RayReanchorPyRecursionLimitsOnCurrentFiber call runs PyGILState_Ensure but skips PyGILState_Release to leak gilstate_counter. That pairing leaves the GIL acquired on that thread after the pre-callback, which can block other worker threads from running Python and matches reported GIL contention under multi-threaded workloads.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de17005. Configure here.

@pseudo-rnd-thoughts

Copy link
Copy Markdown
Member

Closing in favor of #64772

MengjinYan added a commit that referenced this pull request Aug 3, 2026
…protection to fiber stacks (#64772)

# Description

On Python 3.14 + Linux, every async-actor task permanently leaks ~518
KiB of live malloc (the per-task `asyncio.Task`,
`concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack
`Packer`s with 256 KiB internal buffers).
Closes #63290

### Root cause

**1. CPython 3.14 changed how it avoids stack overflow when freeing
objects.** Freeing one object can recursively free many others (a dict
frees its values, which free their contents, …), and each level is a
nested C call. To keep that from overflowing the C stack, CPython has
long had a safety mechanism (the "trashcan"): when it decides it's too
deep, it doesn't free the object right away. Instead it parks the object
on a per-thread *delete-later* list and drains the list once there's
stack headroom again. Up to 3.13, "too deep" was a simple recursion
counter. In 3.14 it's decided by comparing the actual machine **stack
pointer** against the stack bounds CPython recorded for the thread when
it attached (from pthreads, on Linux).

**2. Ray async actors don't run task code on the thread's normal
stack.** Each task executes on a small 256 KiB boost fiber stack
allocated elsewhere in memory. The problem is that CPython still thinks
the thread runs on its original pthread stack.

So while a task runs on a fiber, every "am I near the stack limit?"
check compares the fiber's stack pointer against the *pthread* stack's
bounds. On Linux, fiber stacks happen to be allocated at lower addresses
than the pthread stack, so CPython concludes the stack is hopelessly
overflowed and parks **every** object freed during the task (including
return-value serialization and end-of-task cleanup) on the delete-later
list.

That list is only ever drained by a later free on the same thread state
at a healthy stack margin, which never happens here as the Ray thread
only runs on fibers and Ray creates a fresh Python thread state per task
and destroys it at task end. This means that CPython destroys a thread
state **without draining its delete-later list** and the parked objects
are orphaned permanently. That's the leak.

Why the confusing symptoms:

- `boost::make_fcontext` in the issue's flamegraphs just marks *where*
the leaked allocations were made (on a fiber stack); the fiber stacks
themselves are freed correctly.
- macOS is unaffected only by luck: fiber stacks there land at *higher*
addresses than the pthread stack, so the check passes.
- 3.13 and earlier are unaffected because their trashcan uses the
counter, not the stack pointer.

### Fix

CPython 3.14.2 added an official API for exactly this situation:
`PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets
an embedder tell CPython "this thread is currently executing on *this*
stack." We call it with the fiber's stack bounds:

- at async-actor task entry in `task_execution_handler`, and
- whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers
share the thread state, so each must re-register its own stack).

With the bounds correct, the near-limit check returns to normal
behavior: objects are freed immediately, and the rare genuinely-deep
free is parked and then properly drained.

Implementation notes: the symbol is looked up via `dlsym`, so `_raylet`
still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a
more severe, since-fixed stack-check bug anyway, python/cpython#141944).
No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are
derived from the current stack pointer minus a conservative allowance
for stack already used, so the protection errs toward triggering
slightly early rather than missing an overflow. Side benefit: fibers
gain real C-stack overflow protection (RecursionError) on 3.14, which
they currently lack entirely (`boost::fibers::fixedsize_stack` has no
guard pages). Also makes `FiberState::kStackSize` public so the
anchoring uses the real fiber stack size.

## Related issue number

Closes #63290. Supersedes #63284 (same diagnosis direction, but
hand-rolled `_PyThreadStateImpl` offsets, a deliberate
`gilstate_counter` leak that freezes non-main threads, and a crash
premise that CPython 3.14.2 already fixed upstream).

## Checks

- Verified with a locally built cp314 Linux (aarch64, python:3.14.6
docker) wheel:
- refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks)
- `__del__` deferral probe: dealloc during return serialization on the
fiber **deferred → immediate**
- live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3
KiB/task** across async call → dict/bytes, async generator, sync
generator on async actor
- reporter-shaped streaming workload (400 tasks, 10 concurrent
sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped
regions 0 → 0
- async-actor smoke: correctness (echo, state, async generators,
recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s)
- throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs
baseline, same container image): fixed 5624–6076 tasks/s vs unpatched
4551–4825 tasks/s meaning no regression (the unpatched build is slower
while leaking)
- baseline (unpatched) wheel from the same tree reproduces the bug:
+4.00 refs/task, fiber dealloc deferred=True

note: fable did a majority of the heavy lifting in this investigation
with prompting on what to check next and validate the solution

---------

Signed-off-by: Mark Towers <mark@anyscale.com>
Signed-off-by: myan <myan@anyscale.com>
Co-authored-by: Mark Towers <mark@anyscale.com>
Co-authored-by: myan <myan@anyscale.com>
Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
elliot-barn added a commit that referenced this pull request Aug 4, 2026
#65177)

…protection to fiber stacks (#64772)

# Description

On Python 3.14 + Linux, every async-actor task permanently leaks ~518
KiB of live malloc (the per-task `asyncio.Task`,
`concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack
`Packer`s with 256 KiB internal buffers).
Closes #63290

### Root cause

**1. CPython 3.14 changed how it avoids stack overflow when freeing
objects.** Freeing one object can recursively free many others (a dict
frees its values, which free their contents, …), and each level is a
nested C call. To keep that from overflowing the C stack, CPython has
long had a safety mechanism (the "trashcan"): when it decides it's too
deep, it doesn't free the object right away. Instead it parks the object
on a per-thread *delete-later* list and drains the list once there's
stack headroom again. Up to 3.13, "too deep" was a simple recursion
counter. In 3.14 it's decided by comparing the actual machine **stack
pointer** against the stack bounds CPython recorded for the thread when
it attached (from pthreads, on Linux).

**2. Ray async actors don't run task code on the thread's normal
stack.** Each task executes on a small 256 KiB boost fiber stack
allocated elsewhere in memory. The problem is that CPython still thinks
the thread runs on its original pthread stack.

So while a task runs on a fiber, every "am I near the stack limit?"
check compares the fiber's stack pointer against the *pthread* stack's
bounds. On Linux, fiber stacks happen to be allocated at lower addresses
than the pthread stack, so CPython concludes the stack is hopelessly
overflowed and parks **every** object freed during the task (including
return-value serialization and end-of-task cleanup) on the delete-later
list.

That list is only ever drained by a later free on the same thread state
at a healthy stack margin, which never happens here as the Ray thread
only runs on fibers and Ray creates a fresh Python thread state per task
and destroys it at task end. This means that CPython destroys a thread
state **without draining its delete-later list** and the parked objects
are orphaned permanently. That's the leak.

Why the confusing symptoms:

- `boost::make_fcontext` in the issue's flamegraphs just marks *where*
the leaked allocations were made (on a fiber stack); the fiber stacks
themselves are freed correctly.
- macOS is unaffected only by luck: fiber stacks there land at *higher*
addresses than the pthread stack, so the check passes.
- 3.13 and earlier are unaffected because their trashcan uses the
counter, not the stack pointer.

### Fix

CPython 3.14.2 added an official API for exactly this situation:
`PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets
an embedder tell CPython "this thread is currently executing on *this*
stack." We call it with the fiber's stack bounds:

- at async-actor task entry in `task_execution_handler`, and
- whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers
share the thread state, so each must re-register its own stack).

With the bounds correct, the near-limit check returns to normal
behavior: objects are freed immediately, and the rare genuinely-deep
free is parked and then properly drained.

Implementation notes: the symbol is looked up via `dlsym`, so `_raylet`
still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a
more severe, since-fixed stack-check bug anyway, python/cpython#141944).
No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are
derived from the current stack pointer minus a conservative allowance
for stack already used, so the protection errs toward triggering
slightly early rather than missing an overflow. Side benefit: fibers
gain real C-stack overflow protection (RecursionError) on 3.14, which
they currently lack entirely (`boost::fibers::fixedsize_stack` has no
guard pages). Also makes `FiberState::kStackSize` public so the
anchoring uses the real fiber stack size.

## Related issue number

Closes #63290. Supersedes #63284 (same diagnosis direction, but
hand-rolled `_PyThreadStateImpl` offsets, a deliberate
`gilstate_counter` leak that freezes non-main threads, and a crash
premise that CPython 3.14.2 already fixed upstream).

## Checks

- Verified with a locally built cp314 Linux (aarch64, python:3.14.6
docker) wheel:
- refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks)
- `__del__` deferral probe: dealloc during return serialization on the
fiber **deferred → immediate**
- live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3
KiB/task** across async call → dict/bytes, async generator, sync
generator on async actor
- reporter-shaped streaming workload (400 tasks, 10 concurrent
sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped
regions 0 → 0
- async-actor smoke: correctness (echo, state, async generators,
recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s)
- throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs
baseline, same container image): fixed 5624–6076 tasks/s vs unpatched
4551–4825 tasks/s meaning no regression (the unpatched build is slower
while leaking)
- baseline (unpatched) wheel from the same tree reproduces the bug:
+4.00 refs/task, fiber dealloc deferred=True

note: fable did a majority of the heavy lifting in this investigation
with prompting on what to check next and validate the solution

---------






(cherry picked from commit 35591ba)

Signed-off-by: Mark Towers <mark@anyscale.com>
Signed-off-by: myan <myan@anyscale.com>
Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
Co-authored-by: Mark Towers <mark.m.towers@gmail.com>
Co-authored-by: Mark Towers <mark@anyscale.com>
Co-authored-by: myan <myan@anyscale.com>
Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
Artimislyy pushed a commit to Artimislyy/ray that referenced this pull request Aug 11, 2026
…protection to fiber stacks (ray-project#64772)

# Description

On Python 3.14 + Linux, every async-actor task permanently leaks ~518
KiB of live malloc (the per-task `asyncio.Task`,
`concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack
`Packer`s with 256 KiB internal buffers).
Closes ray-project#63290

### Root cause

**1. CPython 3.14 changed how it avoids stack overflow when freeing
objects.** Freeing one object can recursively free many others (a dict
frees its values, which free their contents, …), and each level is a
nested C call. To keep that from overflowing the C stack, CPython has
long had a safety mechanism (the "trashcan"): when it decides it's too
deep, it doesn't free the object right away. Instead it parks the object
on a per-thread *delete-later* list and drains the list once there's
stack headroom again. Up to 3.13, "too deep" was a simple recursion
counter. In 3.14 it's decided by comparing the actual machine **stack
pointer** against the stack bounds CPython recorded for the thread when
it attached (from pthreads, on Linux).

**2. Ray async actors don't run task code on the thread's normal
stack.** Each task executes on a small 256 KiB boost fiber stack
allocated elsewhere in memory. The problem is that CPython still thinks
the thread runs on its original pthread stack.

So while a task runs on a fiber, every "am I near the stack limit?"
check compares the fiber's stack pointer against the *pthread* stack's
bounds. On Linux, fiber stacks happen to be allocated at lower addresses
than the pthread stack, so CPython concludes the stack is hopelessly
overflowed and parks **every** object freed during the task (including
return-value serialization and end-of-task cleanup) on the delete-later
list.

That list is only ever drained by a later free on the same thread state
at a healthy stack margin, which never happens here as the Ray thread
only runs on fibers and Ray creates a fresh Python thread state per task
and destroys it at task end. This means that CPython destroys a thread
state **without draining its delete-later list** and the parked objects
are orphaned permanently. That's the leak.

Why the confusing symptoms:

- `boost::make_fcontext` in the issue's flamegraphs just marks *where*
the leaked allocations were made (on a fiber stack); the fiber stacks
themselves are freed correctly.
- macOS is unaffected only by luck: fiber stacks there land at *higher*
addresses than the pthread stack, so the check passes.
- 3.13 and earlier are unaffected because their trashcan uses the
counter, not the stack pointer.

### Fix

CPython 3.14.2 added an official API for exactly this situation:
`PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets
an embedder tell CPython "this thread is currently executing on *this*
stack." We call it with the fiber's stack bounds:

- at async-actor task entry in `task_execution_handler`, and
- whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers
share the thread state, so each must re-register its own stack).

With the bounds correct, the near-limit check returns to normal
behavior: objects are freed immediately, and the rare genuinely-deep
free is parked and then properly drained.

Implementation notes: the symbol is looked up via `dlsym`, so `_raylet`
still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a
more severe, since-fixed stack-check bug anyway, python/cpython#141944).
No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are
derived from the current stack pointer minus a conservative allowance
for stack already used, so the protection errs toward triggering
slightly early rather than missing an overflow. Side benefit: fibers
gain real C-stack overflow protection (RecursionError) on 3.14, which
they currently lack entirely (`boost::fibers::fixedsize_stack` has no
guard pages). Also makes `FiberState::kStackSize` public so the
anchoring uses the real fiber stack size.

## Related issue number

Closes ray-project#63290. Supersedes ray-project#63284 (same diagnosis direction, but
hand-rolled `_PyThreadStateImpl` offsets, a deliberate
`gilstate_counter` leak that freezes non-main threads, and a crash
premise that CPython 3.14.2 already fixed upstream).

## Checks

- Verified with a locally built cp314 Linux (aarch64, python:3.14.6
docker) wheel:
- refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks)
- `__del__` deferral probe: dealloc during return serialization on the
fiber **deferred → immediate**
- live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3
KiB/task** across async call → dict/bytes, async generator, sync
generator on async actor
- reporter-shaped streaming workload (400 tasks, 10 concurrent
sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped
regions 0 → 0
- async-actor smoke: correctness (echo, state, async generators,
recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s)
- throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs
baseline, same container image): fixed 5624–6076 tasks/s vs unpatched
4551–4825 tasks/s meaning no regression (the unpatched build is slower
while leaking)
- baseline (unpatched) wheel from the same tree reproduces the bug:
+4.00 refs/task, fiber dealloc deferred=True

note: fable did a majority of the heavy lifting in this investigation
with prompting on what to check next and validate the solution

---------

Signed-off-by: Mark Towers <mark@anyscale.com>
Signed-off-by: myan <myan@anyscale.com>
Co-authored-by: Mark Towers <mark@anyscale.com>
Co-authored-by: myan <myan@anyscale.com>
Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
400Ping pushed a commit to 400Ping/ray that referenced this pull request Aug 18, 2026
…protection to fiber stacks (ray-project#64772)

# Description

On Python 3.14 + Linux, every async-actor task permanently leaks ~518
KiB of live malloc (the per-task `asyncio.Task`,
`concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack
`Packer`s with 256 KiB internal buffers).
Closes ray-project#63290

### Root cause

**1. CPython 3.14 changed how it avoids stack overflow when freeing
objects.** Freeing one object can recursively free many others (a dict
frees its values, which free their contents, …), and each level is a
nested C call. To keep that from overflowing the C stack, CPython has
long had a safety mechanism (the "trashcan"): when it decides it's too
deep, it doesn't free the object right away. Instead it parks the object
on a per-thread *delete-later* list and drains the list once there's
stack headroom again. Up to 3.13, "too deep" was a simple recursion
counter. In 3.14 it's decided by comparing the actual machine **stack
pointer** against the stack bounds CPython recorded for the thread when
it attached (from pthreads, on Linux).

**2. Ray async actors don't run task code on the thread's normal
stack.** Each task executes on a small 256 KiB boost fiber stack
allocated elsewhere in memory. The problem is that CPython still thinks
the thread runs on its original pthread stack.

So while a task runs on a fiber, every "am I near the stack limit?"
check compares the fiber's stack pointer against the *pthread* stack's
bounds. On Linux, fiber stacks happen to be allocated at lower addresses
than the pthread stack, so CPython concludes the stack is hopelessly
overflowed and parks **every** object freed during the task (including
return-value serialization and end-of-task cleanup) on the delete-later
list.

That list is only ever drained by a later free on the same thread state
at a healthy stack margin, which never happens here as the Ray thread
only runs on fibers and Ray creates a fresh Python thread state per task
and destroys it at task end. This means that CPython destroys a thread
state **without draining its delete-later list** and the parked objects
are orphaned permanently. That's the leak.

Why the confusing symptoms:

- `boost::make_fcontext` in the issue's flamegraphs just marks *where*
the leaked allocations were made (on a fiber stack); the fiber stacks
themselves are freed correctly.
- macOS is unaffected only by luck: fiber stacks there land at *higher*
addresses than the pthread stack, so the check passes.
- 3.13 and earlier are unaffected because their trashcan uses the
counter, not the stack pointer.

### Fix

CPython 3.14.2 added an official API for exactly this situation:
`PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets
an embedder tell CPython "this thread is currently executing on *this*
stack." We call it with the fiber's stack bounds:

- at async-actor task entry in `task_execution_handler`, and
- whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers
share the thread state, so each must re-register its own stack).

With the bounds correct, the near-limit check returns to normal
behavior: objects are freed immediately, and the rare genuinely-deep
free is parked and then properly drained.

Implementation notes: the symbol is looked up via `dlsym`, so `_raylet`
still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a
more severe, since-fixed stack-check bug anyway, python/cpython#141944).
No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are
derived from the current stack pointer minus a conservative allowance
for stack already used, so the protection errs toward triggering
slightly early rather than missing an overflow. Side benefit: fibers
gain real C-stack overflow protection (RecursionError) on 3.14, which
they currently lack entirely (`boost::fibers::fixedsize_stack` has no
guard pages). Also makes `FiberState::kStackSize` public so the
anchoring uses the real fiber stack size.

## Related issue number

Closes ray-project#63290. Supersedes ray-project#63284 (same diagnosis direction, but
hand-rolled `_PyThreadStateImpl` offsets, a deliberate
`gilstate_counter` leak that freezes non-main threads, and a crash
premise that CPython 3.14.2 already fixed upstream).

## Checks

- Verified with a locally built cp314 Linux (aarch64, python:3.14.6
docker) wheel:
- refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks)
- `__del__` deferral probe: dealloc during return serialization on the
fiber **deferred → immediate**
- live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3
KiB/task** across async call → dict/bytes, async generator, sync
generator on async actor
- reporter-shaped streaming workload (400 tasks, 10 concurrent
sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped
regions 0 → 0
- async-actor smoke: correctness (echo, state, async generators,
recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s)
- throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs
baseline, same container image): fixed 5624–6076 tasks/s vs unpatched
4551–4825 tasks/s meaning no regression (the unpatched build is slower
while leaking)
- baseline (unpatched) wheel from the same tree reproduces the bug:
+4.00 refs/task, fiber dealloc deferred=True

note: fable did a majority of the heavy lifting in this investigation
with prompting on what to check next and validate the solution

---------

Signed-off-by: Mark Towers <mark@anyscale.com>
Signed-off-by: myan <myan@anyscale.com>
Co-authored-by: Mark Towers <mark@anyscale.com>
Co-authored-by: myan <myan@anyscale.com>
Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
Signed-off-by: 400Ping <jiekaichang@apache.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ray fails to serialize self-reference objects

4 participants