Skip to content

Commit 1823291

Browse files
feat(profiling): support Python 3.14 (#15546)
## Description This PR adds support for Python 3.14 in the profiler by updating it to handle CPython internal changes. ### Key CPython changes addressed **`_PyInterpreterFrame` Structure Changes** 1. Moved from `Include/internal/pycore_frame.h` to `Include/internal/pycore_interpframe_structs.h` 2. `PyObject *f_executable` and `PyObject *f_funcobj` changed to `_PyStackRef` type. Profilers like us now need to clear the LSB of these fields to get the `PyObject*`. See python/cpython#123923 for details 3. `int stacktop` field removed, replaced with `_PyStackRef *stackpointer` pointer. See python/cpython#121923 (GH-120024) for details 4. `PyObject *localsplus[1]` changed to `_PyStackRef localsplus[1]`. See python/cpython#118450 (gh-117139) for details **`FutureObj`/`TaskObj` Changes** 1. Added fields: `awaited_by`, `is_task`, `awaited_by_is_set` in `FutureObj_HEAD` macro 2. Added `struct llist_node_task_node` field for linked-list storage **Asyncio Task Storage Changes** Prior to Python 3.14, - All tasks are stored in `_scheduled_tasks` WeakSet ([exported](https://github.com/python/cpython/blob/e96367da1fdc1e1cf17ca523e93a127b1961b443/Modules/_asynciomodule.c#L3738) from C extension) - Eager tasks are stored in `_eager_tasks` set ([exported](https://github.com/python/cpython/blob/e96367da1fdc1e1cf17ca523e93a127b1961b443/Modules/_asynciomodule.c#L3742) from C extension) From Python 3.14, - Native `asyncio.Tasks` are stored in a linked-list (`struct llist_node`) per thread and per interpreter - [Per-thread](https://github.com/python/cpython/blob/0114178911f8713bfcb935ff5542fe61b4a5d551/Include/internal/pycore_tstate.h#L46): `tstate->asyncio_tasks_head` (in `_PyThreadStateImpl`) - [Per-interpreter](https://github.com/python/cpython/blob/0114178911f8713bfcb935ff5542fe61b4a5d551/Include/internal/pycore_interp_structs.h#L897): `interp->asyncio_tasks_head` (for lingering tasks) - Each `TaskObj` has a `task_node` field with `next` and `prev` pointers - Third-party tasks: Still stored in Python-level `_scheduled_tasks` WeakSet (now Python-only, not exported from C extension) - Eager tasks: Still stored in Python-level `_eager_tasks` set ### Implementation Summary - **Frame reading** (`frame.h`, `frame.cc`): Updated header includes to use `pycore_interpframe_structs.h` for Python 3.14+. Implemented tagged pointer handling: clear LSB of `f_executable` to recover `PyObject*` (per gh-123923). Replaced `stacktop` field access with `stackpointer` pointer arithmetic for stack depth calculation. Updated `PyGen_yf()` to use `_PyStackRef` and `stackpointer[-1]` instead of `localsplus[stacktop-1]`. Added handling for `FRAME_OWNED_BY_INTERPRETER` frame type (introduced in 3.14). - **Task structures** (`cpython/tasks.h`): Added Python 3.14+ `FutureObj_HEAD` macro with new fields: `awaited_by`, `is_task`, `awaited_by_is_set`. Added `struct llist_node task_node` field to `TaskObj` for linked-list storage. Updated `PyGen_yf()` implementation to handle `_PyStackRef` and `stackpointer` instead of `stacktop`. - **Asyncio discovery** (`tasks.h`, `threads.h`): Implemented `get_tasks_from_linked_list()` to safely iterate over circular linked-lists with iteration limits (`MAX_ITERATIONS = 2 << 15`). Added `get_tasks_from_thread_linked_list()` to read tasks from `_PyThreadStateImpl.asyncio_tasks_head` (per-thread active tasks). Added `get_tasks_from_interpreter_linked_list()` to read lingering tasks from `PyInterpreterState.asyncio_tasks_head` (per-interpreter). Updated `get_all_tasks()` to handle both linked-list (native `asyncio.Task` instances) and WeakSet (third-party tasks). - **Python integration** (`_asyncio.py`): Added compatibility handling for `BaseDefaultEventLoopPolicy` → `_BaseDefaultEventLoopPolicy` rename in 3.14. Updated `_scheduled_tasks` access to handle Python-only WeakSet (no longer exported from C extension in 3.14+). ## Testing All existing tests pass except for tests/profiling/collector/test_memalloc.py which needed some edits. ## Risks <!-- Note any risks associated with this change, or "None" if no risks --> ## Additional Notes --------- Co-authored-by: Brett Langdon <brett.langdon@datadoghq.com>
1 parent 21721ff commit 1823291

19 files changed

Lines changed: 638 additions & 164 deletions

File tree

.riot/requirements/173f5b3.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.14
3+
# by the following command:
4+
#
5+
# pip-compile --allow-unsafe --no-annotate .riot/requirements/173f5b3.in
6+
#
7+
attrs==25.4.0
8+
coverage[toml]==7.12.0
9+
gevent==25.9.1
10+
greenlet==3.3.0
11+
gunicorn[gevent]==23.0.0
12+
hypothesis==6.45.0
13+
iniconfig==2.3.0
14+
jsonschema==4.25.1
15+
jsonschema-specifications==2025.9.1
16+
mock==5.2.0
17+
opentracing==2.4.0
18+
packaging==25.0
19+
pluggy==1.6.0
20+
protobuf==6.33.1
21+
py-cpuinfo==8.0.0
22+
pygments==2.19.2
23+
pytest==9.0.1
24+
pytest-asyncio==0.21.1
25+
pytest-benchmark==5.2.3
26+
pytest-cov==7.0.0
27+
pytest-cpp==2.6.0
28+
pytest-mock==3.15.1
29+
pytest-randomly==4.0.1
30+
referencing==0.37.0
31+
rpds-py==0.30.0
32+
sortedcontainers==2.4.0
33+
uwsgi==2.0.31
34+
zope-event==6.1
35+
zope-interface==8.1.1
36+
zstandard==0.25.0

.riot/requirements/1a4c947.txt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.14
3+
# by the following command:
4+
#
5+
# pip-compile --allow-unsafe --no-annotate .riot/requirements/1a4c947.in
6+
#
7+
attrs==25.4.0
8+
coverage[toml]==7.12.0
9+
gunicorn==23.0.0
10+
hypothesis==6.45.0
11+
iniconfig==2.3.0
12+
jsonschema==4.25.1
13+
jsonschema-specifications==2025.9.1
14+
mock==5.2.0
15+
opentracing==2.4.0
16+
packaging==25.0
17+
pluggy==1.6.0
18+
protobuf==6.33.1
19+
py-cpuinfo==8.0.0
20+
pygments==2.19.2
21+
pytest==9.0.1
22+
pytest-asyncio==0.21.1
23+
pytest-benchmark==5.2.3
24+
pytest-cov==7.0.0
25+
pytest-cpp==2.6.0
26+
pytest-mock==3.15.1
27+
pytest-randomly==4.0.1
28+
referencing==0.37.0
29+
rpds-py==0.30.0
30+
sortedcontainers==2.4.0
31+
zstandard==0.25.0

.riot/requirements/72ed1ec.txt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.14
3+
# by the following command:
4+
#
5+
# pip-compile --allow-unsafe --no-annotate .riot/requirements/72ed1ec.in
6+
#
7+
attrs==25.4.0
8+
coverage[toml]==7.12.0
9+
gunicorn==23.0.0
10+
hypothesis==6.45.0
11+
iniconfig==2.3.0
12+
jsonschema==4.25.1
13+
jsonschema-specifications==2025.9.1
14+
mock==5.2.0
15+
opentracing==2.4.0
16+
packaging==25.0
17+
pluggy==1.6.0
18+
protobuf==6.33.1
19+
py-cpuinfo==8.0.0
20+
pygments==2.19.2
21+
pytest==9.0.1
22+
pytest-asyncio==0.21.1
23+
pytest-benchmark==5.2.3
24+
pytest-cov==7.0.0
25+
pytest-cpp==2.6.0
26+
pytest-mock==3.15.1
27+
pytest-randomly==4.0.1
28+
referencing==0.37.0
29+
rpds-py==0.30.0
30+
sortedcontainers==2.4.0
31+
uwsgi==2.0.31
32+
zstandard==0.25.0

ddtrace/internal/datadog/profiling/stack_v2/echion/echion/cpython/tasks.h

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,18 @@
1111
#include <cpython/genobject.h>
1212

1313
#define Py_BUILD_CORE
14-
#if PY_VERSION_HEX >= 0x030d0000
14+
#if PY_VERSION_HEX >= 0x030e0000
15+
#include <cstddef>
16+
#include <internal/pycore_frame.h>
17+
#include <internal/pycore_interpframe.h>
18+
#include <internal/pycore_interpframe_structs.h>
19+
#include <internal/pycore_llist.h>
20+
#include <internal/pycore_stackref.h>
21+
#include <opcode.h>
22+
#elif PY_VERSION_HEX >= 0x030d0000
1523
#include <opcode.h>
1624
#else
25+
#include <cstddef>
1726
#include <internal/pycore_frame.h>
1827
#include <internal/pycore_opcode.h>
1928
#endif // PY_VERSION_HEX >= 0x030d0000
@@ -38,7 +47,32 @@ extern "C"
3847
STATE_FINISHED
3948
} fut_state;
4049

41-
#if PY_VERSION_HEX >= 0x030d0000
50+
#if PY_VERSION_HEX >= 0x030e0000
51+
// Python 3.14+: New fields added (awaited_by, is_task, awaited_by_is_set)
52+
#define FutureObj_HEAD(prefix) \
53+
PyObject_HEAD PyObject* prefix##_loop; \
54+
PyObject* prefix##_callback0; \
55+
PyObject* prefix##_context0; \
56+
PyObject* prefix##_callbacks; \
57+
PyObject* prefix##_exception; \
58+
PyObject* prefix##_exception_tb; \
59+
PyObject* prefix##_result; \
60+
PyObject* prefix##_source_tb; \
61+
PyObject* prefix##_cancel_msg; \
62+
PyObject* prefix##_cancelled_exc; \
63+
PyObject* prefix##_awaited_by; \
64+
fut_state prefix##_state; \
65+
/* Used by profilers to make traversing the stack from an external \
66+
process faster. */ \
67+
char prefix##_is_task; \
68+
char prefix##_awaited_by_is_set; \
69+
/* These bitfields need to be at the end of the struct \
70+
so that these and bitfields from TaskObj are contiguous. \
71+
*/ \
72+
unsigned prefix##_log_tb : 1; \
73+
unsigned prefix##_blocking : 1;
74+
75+
#elif PY_VERSION_HEX >= 0x030d0000
4276
#define FutureObj_HEAD(prefix) \
4377
PyObject_HEAD PyObject* prefix##_loop; \
4478
PyObject* prefix##_callback0; \
@@ -131,7 +165,24 @@ extern "C"
131165
FutureObj_HEAD(future)
132166
} FutureObj;
133167

134-
#if PY_VERSION_HEX >= 0x030d0000
168+
#if PY_VERSION_HEX >= 0x030e0000
169+
// Python 3.14+: TaskObj includes task_node for linked-list storage
170+
typedef struct
171+
{
172+
FutureObj_HEAD(task) unsigned task_must_cancel : 1;
173+
unsigned task_log_destroy_pending : 1;
174+
int task_num_cancels_requested;
175+
PyObject* task_fut_waiter;
176+
PyObject* task_coro;
177+
PyObject* task_name;
178+
PyObject* task_context;
179+
struct llist_node task_node;
180+
#ifdef Py_GIL_DISABLED
181+
// thread id of the thread where this task was created
182+
uintptr_t task_tid;
183+
#endif
184+
} TaskObj;
185+
#elif PY_VERSION_HEX >= 0x030d0000
135186
typedef struct
136187
{
137188
FutureObj_HEAD(task) unsigned task_must_cancel : 1;
@@ -173,7 +224,67 @@ extern "C"
173224
#define RESUME_QUICK INSTRUMENTED_RESUME
174225
#endif
175226

176-
#if PY_VERSION_HEX >= 0x030d0000
227+
#if PY_VERSION_HEX >= 0x030e0000
228+
// Python 3.14+: Use stackpointer and _PyStackRef
229+
230+
inline PyObject* PyGen_yf(PyGenObject* gen, PyObject* frame_addr)
231+
{
232+
if (gen->gi_frame_state != FRAME_SUSPENDED_YIELD_FROM) {
233+
return nullptr;
234+
}
235+
236+
_PyInterpreterFrame frame;
237+
if (copy_type(frame_addr, frame)) {
238+
return nullptr;
239+
}
240+
241+
// CPython asserts the following:
242+
// assert(f->stackpointer > f->localsplus + _PyFrame_GetCode(f)->co_nlocalsplus);
243+
// assert(!PyStackRef_IsNull(f->stackpointer[-1]));
244+
245+
// Though we have to pay the price of copying the code object, we need
246+
// to do this to catch the case where the stack is empty, as accessing
247+
// frame.stackpointer[-1] would be an undefined behavior.
248+
// This is necessary as frame.stacktop is removed in 3.14.
249+
PyCodeObject code;
250+
auto code_addr = reinterpret_cast<PyCodeObject*>(BITS_TO_PTR_MASKED(frame.f_executable));
251+
if (copy_type(code_addr, code)) {
252+
return nullptr;
253+
}
254+
255+
uintptr_t frame_addr_uint = reinterpret_cast<uintptr_t>(frame_addr);
256+
uintptr_t localsplus_addr = frame_addr_uint + offsetof(_PyInterpreterFrame, localsplus);
257+
// This computes f->localsplus + code.co_nlocalsplus.
258+
uintptr_t stackbase_addr = localsplus_addr + code.co_nlocalsplus * sizeof(_PyStackRef);
259+
260+
uintptr_t stackpointer_addr = reinterpret_cast<uintptr_t>(frame.stackpointer);
261+
// We want stackpointer_addr to be greater than the stackbase_addr,
262+
// that is, the stack is not empty.
263+
if (stackpointer_addr <= stackbase_addr) {
264+
return nullptr;
265+
}
266+
267+
// We can also calculate stacktop and check that it is within a reasonable range.
268+
// Similar to 3.13's stacktop check below.
269+
int stacktop = (int)((stackpointer_addr - stackbase_addr) / sizeof(_PyStackRef));
270+
271+
if (stacktop < 1 || stacktop > MAX_STACK_SIZE) {
272+
return nullptr;
273+
}
274+
275+
// Read the top of stack directly from remote memory
276+
// This is equivalent to CPython's frame.stackpointer[-1].
277+
_PyStackRef top_ref;
278+
if (copy_type(reinterpret_cast<void*>(stackpointer_addr - sizeof(_PyStackRef)), top_ref)) {
279+
return nullptr;
280+
}
281+
282+
// Extract PyObject* from _PyStackRef.bits
283+
// Per Python 3.14 release notes (gh-123923): clear LSB to recover PyObject* pointer
284+
return BITS_TO_PTR_MASKED(top_ref);
285+
}
286+
287+
#elif PY_VERSION_HEX >= 0x030d0000
177288

178289
inline PyObject* PyGen_yf(PyGenObject* gen, PyObject* frame_addr)
179290
{

ddtrace/internal/datadog/profiling/stack_v2/echion/echion/frame.h

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@
1414
#undef _PyGC_FINALIZED
1515
#endif
1616
#include <frameobject.h>
17-
#if PY_VERSION_HEX >= 0x030d0000
17+
#if PY_VERSION_HEX >= 0x030e0000
1818
#define Py_BUILD_CORE
19-
#include <internal/pycore_code.h>
20-
#endif // PY_VERSION_HEX >= 0x030d0000
21-
#if PY_VERSION_HEX >= 0x030b0000
19+
#include <internal/pycore_interpframe_structs.h>
20+
#elif PY_VERSION_HEX >= 0x030b0000
2221
#define Py_BUILD_CORE
2322
#include <internal/pycore_frame.h>
2423
#endif

ddtrace/internal/datadog/profiling/stack_v2/echion/echion/greenlets.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
#include <Python.h>
88
#define Py_BUILD_CORE
99

10+
#if PY_VERSION_HEX >= 0x030e0000
11+
#include <internal/pycore_frame.h>
12+
#endif
13+
1014
#include <echion/stacks.h>
1115
#include <echion/strings.h>
1216

ddtrace/internal/datadog/profiling/stack_v2/echion/echion/state.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
#endif
1818
#define Py_BUILD_CORE
1919
#include <internal/pycore_pystate.h>
20+
#if PY_VERSION_HEX >= 0x030e0000
21+
#include <internal/pycore_runtime.h>
22+
#endif
2023

2124
#include <thread>
2225

ddtrace/internal/datadog/profiling/stack_v2/echion/echion/tasks.h

Lines changed: 7 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@
44

55
#pragma once
66

7-
#include <optional>
8-
97
#define PY_SSIZE_T_CLEAN
108
#include <Python.h>
11-
#include <weakrefobject.h>
9+
#include <frameobject.h>
1210

1311
#if PY_VERSION_HEX >= 0x030b0000
1412
#include <cpython/genobject.h>
1513

1614
#define Py_BUILD_CORE
17-
#if PY_VERSION_HEX >= 0x030d0000
15+
#include <cstddef>
16+
#if PY_VERSION_HEX >= 0x030e0000
17+
#include <internal/pycore_frame.h>
18+
#include <opcode.h>
19+
#elif PY_VERSION_HEX >= 0x030d0000
1820
#include <opcode.h>
1921
#else
22+
#include <internal/pycore_frame.h>
2023
#include <internal/pycore_opcode.h>
2124
#endif // PY_VERSION_HEX >= 0x030d0000
2225
#else
@@ -275,67 +278,6 @@ TaskInfo::current(PyObject* loop)
275278
return TaskInfo::create(reinterpret_cast<TaskObj*>(task));
276279
}
277280

278-
// ----------------------------------------------------------------------------
279-
// TODO: Make this a "for_each_task" function?
280-
[[nodiscard]] inline Result<std::vector<TaskInfo::Ptr>>
281-
get_all_tasks(PyObject* loop)
282-
{
283-
std::vector<TaskInfo::Ptr> tasks;
284-
if (loop == NULL)
285-
return tasks;
286-
287-
auto maybe_scheduled_tasks_set = MirrorSet::create(asyncio_scheduled_tasks);
288-
if (!maybe_scheduled_tasks_set) {
289-
return ErrorKind::TaskInfoError;
290-
}
291-
292-
auto scheduled_tasks_set = std::move(*maybe_scheduled_tasks_set);
293-
auto maybe_scheduled_tasks = scheduled_tasks_set.as_unordered_set();
294-
if (!maybe_scheduled_tasks) {
295-
return ErrorKind::TaskInfoError;
296-
}
297-
298-
auto scheduled_tasks = std::move(*maybe_scheduled_tasks);
299-
for (auto task_wr_addr : scheduled_tasks) {
300-
PyWeakReference task_wr;
301-
if (copy_type(task_wr_addr, task_wr))
302-
continue;
303-
304-
auto maybe_task_info = TaskInfo::create(reinterpret_cast<TaskObj*>(task_wr.wr_object));
305-
if (maybe_task_info) {
306-
if ((*maybe_task_info)->loop == loop) {
307-
tasks.push_back(std::move(*maybe_task_info));
308-
}
309-
}
310-
}
311-
312-
if (asyncio_eager_tasks != NULL) {
313-
auto maybe_eager_tasks_set = MirrorSet::create(asyncio_eager_tasks);
314-
if (!maybe_eager_tasks_set) {
315-
return ErrorKind::TaskInfoError;
316-
}
317-
318-
auto eager_tasks_set = std::move(*maybe_eager_tasks_set);
319-
320-
auto maybe_eager_tasks = eager_tasks_set.as_unordered_set();
321-
if (!maybe_eager_tasks) {
322-
return ErrorKind::TaskInfoError;
323-
}
324-
325-
auto eager_tasks = std::move(*maybe_eager_tasks);
326-
for (auto task_addr : eager_tasks) {
327-
auto maybe_task_info = TaskInfo::create(reinterpret_cast<TaskObj*>(task_addr));
328-
if (maybe_task_info) {
329-
if ((*maybe_task_info)->loop == loop) {
330-
tasks.push_back(std::move(*maybe_task_info));
331-
}
332-
}
333-
}
334-
}
335-
336-
return tasks;
337-
}
338-
339281
// ----------------------------------------------------------------------------
340282

341283
inline std::vector<std::unique_ptr<StackInfo>> current_tasks;

0 commit comments

Comments
 (0)