Skip to content

Commit 50cbaab

Browse files
misc. code cleanup for Python native-heap profiling
1 parent 37d2e79 commit 50cbaab

8 files changed

Lines changed: 139 additions & 65 deletions

File tree

.gitlab/scripts/build-wheel-helpers.sh

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,22 +151,49 @@ PY
151151
# Repair wheel (ONLY PLATFORM-SPECIFIC CODE)
152152
section_start "repair_wheel" "Repairing wheel"
153153
if [[ "$(uname -s)" == "Linux" ]]; then
154-
# The opt-in heap-gotter cdylib (DD_PROFILING_NATIVE_HEAP_BUILD=1) has
155-
# non-standard ELF versioning sections that trip auditwheel's iter_versions
156-
# parser. --exclude does not help: it only drops a SONAME from dependency
157-
# grafting, while repair still parses every ELF listed in the wheel's RECORD.
158-
# So the cdylib has to leave the wheel entirely and be reinserted after.
154+
# Heap-gotter's ELF versioning trips auditwheel iter_versions; --exclude
155+
# only skips SONAME grafting, so stash .so (+ .so.debug) out of the wheel,
156+
# repair, then reinsert the runtime .so. Python zipfile: Info-ZIP globs are
157+
# unreliable on archive paths.
159158
GOTTER_STASH_DIR="${WORK_DIR}/heap_gotter_stash"
160-
GOTTER_PATTERN='*libdd_heap_gotter*.so'
161-
if unzip -l "${BUILT_WHEEL_FILE}" | grep -q 'libdd_heap_gotter.*\.so$'; then
162-
mkdir -p "${GOTTER_STASH_DIR}"
163-
unzip -q "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}" -d "${GOTTER_STASH_DIR}"
164-
uv run --no-project scripts/zip_filter.py "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}"
165-
fi
159+
mkdir -p "${GOTTER_STASH_DIR}"
160+
BUILT_WHEEL_FILE="${BUILT_WHEEL_FILE}" GOTTER_STASH_DIR="${GOTTER_STASH_DIR}" \
161+
uv run --no-project python - <<'PY'
162+
import os
163+
import zipfile
164+
from pathlib import Path
165+
166+
import subprocess
167+
import sys
168+
169+
wheel = Path(os.environ["BUILT_WHEEL_FILE"])
170+
stash = Path(os.environ["GOTTER_STASH_DIR"])
171+
marker = "libdd_heap_gotter"
172+
with zipfile.ZipFile(wheel, "r") as zf:
173+
gotter = [
174+
n
175+
for n in zf.namelist()
176+
if marker in Path(n).name and (n.endswith(".so") or n.endswith(".so.debug"))
177+
]
178+
for name in gotter:
179+
dest = stash / name
180+
dest.parent.mkdir(parents=True, exist_ok=True)
181+
dest.write_bytes(zf.read(name))
182+
print(f"Stashed heap-gotter artifact: {name}")
183+
if gotter:
184+
# zip_filter keeps RECORD consistent.
185+
patterns = [f"*{marker}*.so", f"*{marker}*.so.debug", f"*/{marker}*"]
186+
subprocess.check_call([sys.executable, "scripts/zip_filter.py", str(wheel), *patterns])
187+
with zipfile.ZipFile(wheel, "r") as zf:
188+
leftover = [n for n in zf.namelist() if marker in Path(n).name]
189+
if leftover:
190+
raise SystemExit(f"heap-gotter still in wheel before auditwheel: {leftover}")
191+
print(f"heap-gotter stash count before auditwheel: {len(gotter)}")
192+
PY
166193

167194
auditwheel repair -w "${TMP_WHEEL_DIR}" "${BUILT_WHEEL_FILE}"
168195

169-
if [[ -d "${GOTTER_STASH_DIR}" ]]; then
196+
if find "${GOTTER_STASH_DIR}" \( -name 'libdd_heap_gotter*.so' -o -name 'libdd_heap_gotter*.so.debug' \) 2>/dev/null | grep -q .; then
170197
REPAIRED_WHEEL_FILE=$(ls "${TMP_WHEEL_DIR}"/*.whl | head -n 1)
171198
GOTTER_STASH_DIR="${GOTTER_STASH_DIR}" REPAIRED_WHEEL_FILE="${REPAIRED_WHEEL_FILE}" \
172199
uv run --no-project python - <<'PY'
@@ -181,7 +208,12 @@ from pathlib import Path
181208
wheel = Path(os.environ["REPAIRED_WHEEL_FILE"])
182209
stash = Path(os.environ["GOTTER_STASH_DIR"])
183210
184-
additions = {str(p.relative_to(stash)): p for p in sorted(stash.rglob("*")) if p.is_file()}
211+
# Runtime .so only; .so.debug stays in debugwheelhouse.
212+
additions = {
213+
str(p.relative_to(stash)): p
214+
for p in sorted(stash.rglob("*"))
215+
if p.is_file() and p.name.endswith(".so")
216+
}
185217
if not additions:
186218
print("No stashed heap-gotter cdylib to reinsert")
187219
raise SystemExit(0)
Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,28 @@
11
"""Activator for native (C/C++) heap allocation profiling via GOT rewriting.
22
3-
This module dlopen's the `libdd_heap_gotter` cdylib (built out-of-band from
4-
libdatadog's `libdd-profiling-heap-gotter-ffi`; see `src/native_heap_gotter`)
5-
and drives it through a tiny, stable C ABI:
6-
7-
bool ddtrace_heap_gotter_install(void); # install + report success
8-
bool ddtrace_heap_gotter_is_installed(void); # current install state
9-
10-
Calling `install()` patches the process's GOT entries for heap allocation
11-
symbols so that Datadog's `ddheap:alloc` USDT probe sites fire on sampled allocations.
12-
The Full Host eBPF profiler then attaches uprobes to those sites to collect native allocation stacks.
13-
14-
If the cdylib is missing or anything goes wrong loading it, `is_available` is `False`
15-
and `install()` is a no-op. Loading this module must never raise.
16-
17-
Installation cannot be undone (the patched GOT entries point at functions inside the cdylib),
18-
so the library must stay mapped for the life of the process. We keep the `ctypes.CDLL` handle
19-
at module scope and never unload it.
20-
21-
After a successful `install()`, a child of `fork()` inherits the mapping and the patched GOT.
22-
Re-entering `install()` in that child is therefore unnecessary; we skip the native call when
23-
this module already recorded a successful arm (Python module state is also inherited). That
24-
avoids re-locking upstream's process-global registry mutex. Upstream does not yet implement a
25-
`pthread_atfork` child reset, so forking *during* an in-flight `install()`/`update()` can still
26-
leave that mutex locked in the child — prefer arming on the main thread, or after fork in the
27-
worker (gunicorn/uWSGI-style), and treat mid-install fork as unsafe until libdatadog lands
28-
atfork handling.
3+
Dlopens ``libdd_heap_gotter`` (see ``src/native_heap_gotter``) and drives:
4+
5+
bool ddtrace_heap_gotter_install(void);
6+
bool ddtrace_heap_gotter_is_installed(void);
7+
bool ddtrace_heap_gotter_live_heap_enabled(void); # True if built with ddheap:free
8+
9+
``install()`` patches GOT entries so ``ddheap:alloc`` (and, on live-heap builds,
10+
``ddheap:free``) USDT sites fire; the Full Host eBPF profiler attaches uprobes.
11+
Nothing is collected or uploaded from Python.
12+
13+
``live_heap_enabled()`` is a compile-time property of the loaded artifact
14+
(default-on ``live-heap`` feature): True when the cdylib stamps retain flags and
15+
emits ``ddheap:free``. False if the cdylib is missing, alloc-only
16+
(``--no-default-features``), or predates this symbol (bound defensively).
17+
18+
Missing/broken load → ``is_available`` False, ``install()`` no-op; import never
19+
raises. Install is permanent (GOT points into the cdylib), so the CDLL handle
20+
stays mapped for the process lifetime.
21+
22+
After a successful ``install()``, fork children inherit the mapping and patched
23+
GOT; ``_armed`` skips re-entering the native installer. Upstream has no
24+
``pthread_atfork`` reset for its registry mutex — prefer arming on the main
25+
thread or in the worker after fork; mid-install fork is unsafe.
2926
"""
3027

3128
from __future__ import annotations
@@ -35,27 +32,26 @@
3532
import sysconfig
3633

3734

38-
# Mirror the ddup/stack modules: importers (notably settings/profiling.py) read
39-
# these two attributes to decide whether the feature can run.
35+
# Mirror ddup/stack: settings/profiling.py reads these to gate the feature.
4036
is_available: bool = False
4137
failure_msg: str = ""
4238

43-
_lib: ctypes.CDLL | None = None # kept alive for process lifetime; never dlclose'd
44-
# Set when install() has succeeded in this process (inherited across fork).
45-
_armed: bool = False
39+
# Compile-time live-heap capability of the loaded artifact (see module docstring).
40+
_live_heap_available: bool = False
41+
42+
_lib: ctypes.CDLL | None = None # process lifetime; never dlclose'd
43+
_armed: bool = False # successful install(); inherited across fork
4644

4745

4846
def _library_path() -> str:
49-
# The cdylib is staged next to libdd_wrapper in the profiling package and
50-
# carries the interpreter EXT_SUFFIX, matching setup.py's naming.
47+
# Staged next to libdd_wrapper with the interpreter EXT_SUFFIX (setup.py).
5148
suffix: str = sysconfig.get_config_var("EXT_SUFFIX") or ".so"
5249
profiling_dir: str = os.path.dirname(os.path.dirname(__file__))
5350
return os.path.join(profiling_dir, "libdd_heap_gotter" + suffix)
5451

5552

5653
try:
57-
# Native heap profiling via the gotter is Linux-only; on every other
58-
# platform the underlying library is a no-op, so don't even try to load.
54+
# Linux-only; elsewhere the gotter is a no-op.
5955
sysname = os.uname().sysname if os.name == "posix" else os.name
6056
if sysname != "Linux":
6157
raise OSError(f"Native heap gotter is only supported on Linux. Running on {sysname}")
@@ -64,8 +60,7 @@ def _library_path() -> str:
6460
if not os.path.exists(_path):
6561
raise FileNotFoundError(_path)
6662

67-
# RTLD_GLOBAL so the loaded code is unambiguously resolvable; RTLD_NOW so any
68-
# unresolved symbol fails here (fail-closed) rather than at first call.
63+
# RTLD_GLOBAL for resolvability; RTLD_NOW fail-closed on unresolved symbols.
6964
_lib = ctypes.CDLL(_path, mode=ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0))
7065

7166
_lib.ddtrace_heap_gotter_install.argtypes = []
@@ -75,18 +70,25 @@ def _library_path() -> str:
7570

7671
is_available = True
7772

73+
# Optional symbol (pre-Phase-2 cdylibs); failure must not disable install().
74+
try:
75+
_lib.ddtrace_heap_gotter_live_heap_enabled.argtypes = []
76+
_lib.ddtrace_heap_gotter_live_heap_enabled.restype = ctypes.c_bool
77+
_live_heap_available = bool(_lib.ddtrace_heap_gotter_live_heap_enabled())
78+
except Exception:
79+
_live_heap_available = False
80+
7881
except Exception as e:
7982
failure_msg = str(e)
8083
_lib = None
8184

8285

8386
def install() -> bool:
84-
"""Install the native heap GOT overrides. Returns True if now installed; False otherwise.
87+
"""Install native heap GOT overrides. True if installed; False otherwise.
8588
86-
Idempotent at the Python layer: once a call has succeeded, further calls
87-
(including in a forked child that inherited ``_armed``) return True without
88-
re-entering the native installer. No-op that returns False when the cdylib
89-
is unavailable. See the module docstring for fork-safety limits.
89+
Idempotent at the Python layer: after success (including in a fork child that
90+
inherited ``_armed``), further calls return True without re-entering the native
91+
installer. See module docstring for fork-safety limits.
9092
"""
9193
global _armed
9294
if not is_available or _lib is None:
@@ -112,3 +114,12 @@ def is_installed() -> bool:
112114
return bool(_lib.ddtrace_heap_gotter_is_installed())
113115
except Exception:
114116
return False
117+
118+
119+
def live_heap_enabled() -> bool:
120+
"""Return whether the loaded cdylib was built with live-heap tracking.
121+
122+
Compile-time property of the artifact (default-on feature). False when the
123+
cdylib is missing, alloc-only, or predates this symbol.
124+
"""
125+
return _live_heap_available

ddtrace/profiling/profiler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,8 @@ def _start_service(self) -> None:
386386

387387
try:
388388
if heap_gotter.install():
389-
LOG.info("Native heap profiling armed (GOT overrides installed)")
389+
mode: str = "live-heap" if heap_gotter.live_heap_enabled() else "allocation-only"
390+
LOG.info("Native heap profiling armed (GOT overrides installed, %s)", mode)
390391
else:
391392
LOG.warning("Native heap profiling requested but GOT overrides were not installed")
392393
except Exception:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
features:
3+
- |
4+
profiling: Enable collection of samples for native (C/C++) allocations
5+
in Python processes. Enable with ``DD_PROFILING_NATIVE_HEAP_ENABLED=true``.
6+
This setting works for Linux only.

setup.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,14 @@
135135
# Opt-in build of the native heap-gotter cdylib.
136136
# Off by default so normal builds don't pay the extra cargo fetch/compile and
137137
# mainline wheels don't ship the artifact until it GA's.
138-
BUILD_NATIVE_HEAP_GOTTER: bool = os.getenv("DD_PROFILING_NATIVE_HEAP_BUILD", "0").lower() in ("1", "yes", "on", "true")
138+
# Same env var as runtime arming (ProfilingConfigNativeHeap.enabled); setup.py
139+
# reads it via os.getenv during the package build, independent of DDConfig.
140+
BUILD_NATIVE_HEAP_GOTTER: bool = os.getenv("DD_PROFILING_NATIVE_HEAP_ENABLED", "0").lower() in (
141+
"1",
142+
"yes",
143+
"on",
144+
"true",
145+
)
139146
# Keep the staged cdylib unstripped when building with the upstream test-support
140147
# feature (hook-hit counter for e2e / integration tests).
141148
BUILD_NATIVE_HEAP_GOTTER_TEST_SUPPORT: bool = os.getenv("DD_PROFILING_NATIVE_HEAP_TEST_SUPPORT", "0").lower() in (

src/native_heap_gotter/Cargo.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@ debug = "line-tables-only"
3131
codegen-units = 1
3232

3333
[features]
34+
default = ["live-heap"]
3435
# Forwards to the upstream crate's test-support surface so integration tests can
3536
# assert the patched GOT hooks actually ran (hit counter) without needing a
3637
# live eBPF uprobe attach. Never enabled for shipped wheels.
3738
test-support = ["libdd-profiling-heap-gotter/test-support"]
3839

40+
# Default-on live-heap (ddheap:free + retain flags). Sole enablement path for
41+
# upstream live-heap — keeps cfg! / live_heap_enabled() in lockstep with the
42+
# artifact. Omit via --no-default-features for alloc-only builds.
43+
live-heap = ["libdd-profiling-heap-gotter/live-heap"]
44+
3945
[dependencies]
40-
# Pinned to the published crates.io release. `live-heap` enables balanced
41-
# alloc/free sampling so the profiler can report retained (live) heap.
42-
libdd-profiling-heap-gotter = { version = "1.0.0", features = ["live-heap"] }
46+
# Live-heap only via the feature above, not unconditionally.
47+
libdd-profiling-heap-gotter = { version = "1.0.0" }

src/native_heap_gotter/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ pub extern "C" fn ddtrace_heap_gotter_update() {
6262
libdd_profiling_heap_gotter::update_heap_overrides();
6363
}
6464

65+
/// Compile-time live-heap capability: `true` when built with the `live-heap`
66+
/// feature (`ddheap:free` + retain flagging). Not a runtime toggle; keep in
67+
/// lockstep with Cargo.toml's forward to `libdd-profiling-heap-gotter/live-heap`.
68+
///
69+
/// # Safety
70+
///
71+
/// C ABI entry point with no arguments and no pointers; always safe to call.
72+
#[no_mangle]
73+
pub extern "C" fn ddtrace_heap_gotter_live_heap_enabled() -> bool {
74+
cfg!(feature = "live-heap")
75+
}
76+
6577
/// Test-only: number of times a patched hook has run in this process. Lets
6678
/// integration tests prove the patched GOT was actually exercised without a
6779
/// live eBPF attach. Only present when built with the `test-support` feature;

tests/profiling/test_native_heap_gotter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
The activator (``ddtrace.internal.datadog.profiling.heap_gotter``) is fail-closed
44
and must behave correctly whether or not the opt-in gotter cdylib was built into
5-
the wheel (``DD_PROFILING_NATIVE_HEAP_BUILD=1``):
5+
the wheel (``DD_PROFILING_NATIVE_HEAP_ENABLED=1`` at build time):
66
77
* If the library is absent (the default), ``install()``/``is_installed()`` are
88
no-ops returning ``False``.
@@ -27,17 +27,17 @@ def test_native_heap_gotter_smoke() -> None:
2727
from ddtrace.internal.datadog.profiling import heap_gotter
2828

2929
if not heap_gotter.is_available:
30-
# Wheel built without the gotter cdylib: strictly a no-op.
3130
assert heap_gotter.install() is False
3231
assert heap_gotter.is_installed() is False
32+
assert heap_gotter.live_heap_enabled() is False
3333
else:
34-
# Native-heap build: arming must take effect and be idempotent.
3534
assert heap_gotter.is_installed() is False
3635
assert heap_gotter.install() is True
3736
assert heap_gotter.is_installed() is True
38-
assert heap_gotter.install() is True
37+
assert heap_gotter.install() is True # idempotent
38+
# Default gotter builds enable the live-heap Cargo feature (ddheap:free).
39+
assert heap_gotter.live_heap_enabled() is True
3940

40-
# Generate allocation pressure; this must not crash with the patched GOT.
4141
blobs: list[tuple[str, int]] = []
4242
for i in range(200):
4343
blobs.append(("x" * 4096, i))

0 commit comments

Comments
 (0)