Skip to content

Commit 0b91b78

Browse files
feat(profiling): native live-heap (ddheap:free) for native allocations
Make native heap profiling alloc + live/retained heap in one artifact. A gotter build (DD_PROFILING_NATIVE_HEAP_BUILD=1) now always compiles the `live-heap` cargo feature (a Cargo default), so it flags sampled allocations and emits the ddheap:free USDT alongside ddheap:alloc, letting the Full Host eBPF profiler reconcile frees against allocations and report retained memory rather than only cumulative allocations. There is a single build knob: the earlier separate DD_PROFILING_NATIVE_HEAP_LIVE gate (an artifact of the incremental Phase 1 -> live-heap build-out) is removed and folded into DD_PROFILING_NATIVE_HEAP_BUILD. - wrapper crate: `live-heap` is a default feature, forwarding to the upstream gotter/sampler crate (C define DD_HEAP_LIVE_TRACKING); the free GOT hook is always interposed, the ddheap:free USDT + alloc-side retain flagging come with the feature - C ABI ddtrace_heap_gotter_live_heap_enabled() capability query (defensive: reflects the actual loaded artifact, so an older alloc-only cdylib reads false) - activator binds it defensively, exposes live_heap_enabled() - profiler reports armed mode (live-heap vs allocation-only) The default (non-gotter) build is unchanged. Runtime arming is still gated by DD_PROFILING_NATIVE_HEAP_ENABLED.
1 parent e37b55f commit 0b91b78

7 files changed

Lines changed: 127 additions & 20 deletions

File tree

ddtrace/internal/datadog/profiling/heap_gotter/__init__.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@
44
libdatadog's ``libdd-profiling-heap-gotter-ffi``; see ``src/native_heap_gotter``)
55
and drives it through a tiny, stable C ABI:
66
7-
bool ddtrace_heap_gotter_install(void); # install + report success
8-
bool ddtrace_heap_gotter_is_installed(void); # current install state
7+
bool ddtrace_heap_gotter_install(void); # install + report success
8+
bool ddtrace_heap_gotter_is_installed(void); # current install state
9+
bool ddtrace_heap_gotter_live_heap_enabled(void); # built with ddheap:free?
910
1011
Calling ``install()`` patches the process's GOT entries for heap allocation
11-
symbols so that Datadog's ``ddheap:alloc`` (Phase 1: allocation-only) USDT probe
12-
sites fire on sampled allocations. The Full Host eBPF profiler then attaches
13-
uprobes to those sites to collect native allocation flamegraphs. There is
14-
nothing to collect or upload from the Python side — this only *arms* the probes.
12+
symbols so that Datadog's ``ddheap:alloc`` USDT probe sites fire on sampled
13+
allocations. The Full Host eBPF profiler then attaches uprobes to those sites to
14+
collect native allocation flamegraphs. There is nothing to collect or upload
15+
from the Python side — this only *arms* the probes.
16+
17+
``live_heap_enabled()`` reports whether the loaded cdylib was *built* with
18+
live-heap tracking, in which case it also emits the ``ddheap:free`` USDT and
19+
stamps a per-allocation retain flag so the FH profiler can reconcile frees
20+
against allocations for a live/retained-heap view. Live-heap is a default of the
21+
gotter build, so any current cdylib reports ``True``; the query stays as a
22+
defensive check that reflects the *actual* loaded artifact — an older alloc-only
23+
cdylib (or a cdylib built before this symbol existed) reports ``False`` (the
24+
symbol is bound defensively). This is a compile-time property, not a runtime
25+
toggle.
1526
1627
Fail-closed by design: if the cdylib is missing (the default, since it only
1728
ships when built with ``DD_PROFILING_NATIVE_HEAP_BUILD=1``) or anything goes
@@ -37,6 +48,11 @@
3748
is_available: bool = False
3849
failure_msg: str = ""
3950

51+
# Whether the loaded cdylib was built with live-heap tracking (ddheap:free +
52+
# retain flagging). Compile-time property of the artifact; see module docstring.
53+
# Stays False when the cdylib is absent or was built allocation-only.
54+
_live_heap_available: bool = False
55+
4056
_lib: ctypes.CDLL | None = None # kept alive for process lifetime; never dlclose'd
4157

4258

@@ -69,6 +85,16 @@ def _library_path() -> str:
6985

7086
is_available = True
7187

88+
# Bind the live-heap capability query defensively: it only exists on cdylibs
89+
# built at/after Phase 2. A missing symbol (older alloc-only build) simply
90+
# leaves live-heap reported as unavailable rather than failing the load.
91+
try:
92+
_lib.ddtrace_heap_gotter_live_heap_enabled.argtypes = []
93+
_lib.ddtrace_heap_gotter_live_heap_enabled.restype = ctypes.c_bool
94+
_live_heap_available = bool(_lib.ddtrace_heap_gotter_live_heap_enabled())
95+
except AttributeError:
96+
_live_heap_available = False
97+
7298
except Exception as e:
7399
failure_msg = str(e)
74100
_lib = None
@@ -96,3 +122,15 @@ def is_installed() -> bool:
96122
return bool(_lib.ddtrace_heap_gotter_is_installed())
97123
except Exception:
98124
return False
125+
126+
127+
def live_heap_enabled() -> bool:
128+
"""Return whether the loaded cdylib was built with live-heap tracking.
129+
130+
Live-heap is a default of the gotter build, so any current cdylib returns
131+
True: it emits the ``ddheap:free`` USDT and stamps a per-allocation retain
132+
flag so the FH profiler can reconcile frees against allocations. A missing
133+
cdylib, or an older alloc-only one, returns False. Compile-time property; it
134+
does not change over the process lifetime.
135+
"""
136+
return _live_heap_available

ddtrace/profiling/profiler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,11 @@ def _start_service(self) -> None:
389389

390390
try:
391391
if heap_gotter.install():
392-
LOG.debug("Native heap profiling armed (GOT overrides installed)")
392+
# live-heap (ddheap:free + retain flagging) is a build-time
393+
# property of the cdylib, not a runtime toggle; report which
394+
# mode was actually armed for observability.
395+
mode = "live-heap" if heap_gotter.live_heap_enabled() else "allocation-only"
396+
LOG.debug("Native heap profiling armed (GOT overrides installed, %s)", mode)
393397
else:
394398
LOG.debug("Native heap profiling requested but GOT overrides were not installed")
395399
except Exception:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
features:
2+
- |
3+
profiling: Extend the experimental, opt-in native (C/C++) heap profiling with
4+
live/retained-heap tracking. A gotter build (``DD_PROFILING_NATIVE_HEAP_BUILD=1``)
5+
now flags sampled allocations and emits the ``ddheap:free`` USDT probe in
6+
addition to ``ddheap:alloc``, so the Datadog eBPF profiler can reconcile frees
7+
against allocations and report retained memory, not just cumulative
8+
allocations. This is a build-time capability; the default (non-gotter) build is
9+
unchanged. Runtime arming is still gated by
10+
``DD_PROFILING_NATIVE_HEAP_ENABLED=true``, and the whole feature is a no-op when
11+
disabled.

setup.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,15 @@
128128

129129
BUILD_PROFILING_NATIVE_TESTS = os.getenv("DD_PROFILING_NATIVE_TESTS", "0").lower() in ("1", "yes", "on", "true")
130130

131-
# Opt-in build of the native heap-gotter cdylib (Phase 1: allocation-only native
132-
# heap profiling via GOT rewriting, driven at runtime by the FH eBPF profiler).
133-
# Off by default so mainline wheels are not pinned to a moving libdatadog `main`
134-
# SHA and normal builds don't pay the extra cargo fetch/compile. The staging A/B
135-
# harness sets this to bake the artifact into its custom wheels; runtime install
136-
# is separately gated by DD_PROFILING_NATIVE_HEAP_ENABLED.
131+
# Opt-in build of the native heap-gotter cdylib: allocation + live/retained-heap
132+
# native heap profiling via GOT rewriting, driven at runtime by the FH eBPF
133+
# profiler. When set, the cdylib is built with the `live-heap` cargo feature (a
134+
# Cargo default), so it emits both the `ddheap:alloc` and `ddheap:free` USDTs and
135+
# the FH profiler can reconcile frees against allocations for a retained-heap
136+
# view. Off by default so mainline wheels are not pinned to a moving libdatadog
137+
# `main` SHA and normal builds don't pay the extra cargo fetch/compile. The
138+
# staging A/B harness sets this to bake the artifact into its custom wheels;
139+
# runtime install is separately gated by DD_PROFILING_NATIVE_HEAP_ENABLED.
137140
BUILD_NATIVE_HEAP_GOTTER: bool = os.getenv("DD_PROFILING_NATIVE_HEAP_BUILD", "0").lower() in ("1", "yes", "on", "true")
138141

139142
CURRENT_OS = platform.system()
@@ -1056,6 +1059,9 @@ def build_heap_gotter(self) -> None:
10561059
# render to stderr in human form.
10571060
"--message-format=json-render-diagnostics",
10581061
] + DD_CARGO_ARGS
1062+
# `live-heap` is a default cargo feature (see Cargo.toml), so the built
1063+
# cdylib always emits both the `ddheap:alloc` and `ddheap:free` USDTs
1064+
# (verifiable via `readelf -n`) and stamps per-allocation retain flags.
10591065
proc: subprocess.CompletedProcess[str] = subprocess.run(
10601066
cargo_cmd, check=True, stdout=subprocess.PIPE, text=True
10611067
)

src/native_heap_gotter/Cargo.toml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
# over the upstream crate's pure-Rust API, so the Python ctypes activator
1515
# links against fixed, unmangled symbol names and gets a plain `bool`.
1616
#
17-
# Phase 1 is allocation-only: the `live-heap` cargo feature of the gotter is
18-
# intentionally NOT enabled, so the free-path USDT (`ddheap:free`) and its
19-
# per-free overhead stay compiled out. Enabling live-heap later just adds the
20-
# `live-heap` feature on the `libdd-profiling-heap-gotter` dependency below.
17+
# Native heap profiling is alloc + live/retained heap in one artifact: `live-heap`
18+
# is a DEFAULT cargo feature (see the [features] block), so any gotter build
19+
# (DD_PROFILING_NATIVE_HEAP_BUILD=1) emits both the `ddheap:alloc` and
20+
# `ddheap:free` USDTs, letting the FH eBPF profiler reconcile frees against
21+
# allocations for a retained-heap view. There is no separate alloc-only build
22+
# gate — the free-path USDT + per-allocation retain flagging come from forwarding
23+
# to `libdd-profiling-heap-gotter/live-heap` (see the [features] block below).
2124
[package]
2225
name = "ddtrace-heap-gotter"
2326
version = "0.1.0"
@@ -43,8 +46,22 @@ codegen-units = 1
4346
# live eBPF uprobe attach. Never enabled for shipped wheels.
4447
test-support = ["libdd-profiling-heap-gotter/test-support"]
4548

49+
# AIDEV-NOTE: `live-heap` is a *build-time* feature (not a runtime knob) and is a
50+
# DEFAULT feature, so every gotter build includes it. It forwards to
51+
# libdd-profiling-heap-gotter/live-heap -> libdd-profiling-heap-sampler/live-heap,
52+
# whose build.rs turns the CARGO_FEATURE_LIVE_HEAP cargo feature into the C define
53+
# DD_HEAP_LIVE_TRACKING=1. That define makes (a) dd_probe_free emit the
54+
# `ddheap:free` USDT (otherwise the note is absent from .note.stapsdt) and (b) the
55+
# alloc side stamp the per-allocation retain flag so the free side can reconcile
56+
# it. The `free` GOT hook itself is always interposed upstream. Because the
57+
# `ddheap:free` ELF note is a property of the compiled artifact, live-heap is a
58+
# build-time property and cannot be flipped by the Python activator at runtime.
59+
default = ["live-heap"]
60+
live-heap = ["libdd-profiling-heap-gotter/live-heap"]
61+
4662
[dependencies]
4763
# Pinned to the published crates.io release (Phase 1.5 migration off the
48-
# libdatadog `main` git pin). `default-features = false` keeps the
49-
# allocation-only surface: `live-heap` stays opt-in and off for Phase 1.
64+
# libdatadog `main` git pin). `default-features = false` disables the upstream
65+
# crate's own defaults; our `live-heap` feature (a default here) enables exactly
66+
# the live-heap surface we need on top.
5067
libdd-profiling-heap-gotter = { version = "1.0.0", default-features = false }

src/native_heap_gotter/lib.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
//! `heap_overrides_are_installed`); it is not a C-ABI surface. Here we re-export
1212
//! those calls as fixed, unmangled `extern "C"` entry points returning a plain
1313
//! `bool`, so the Python ctypes side links against stable symbol names and gets
14-
//! a trivial success signal.
14+
//! a trivial success signal. We also expose
15+
//! `ddtrace_heap_gotter_live_heap_enabled`, which reports whether this artifact
16+
//! was built with live-heap tracking (Phase 2: `ddheap:free` + retain flagging).
1517
//!
1618
//! Installation is permanent and process-global: the GOT entries patched by
1719
//! `install` point at functions inside the linked-in gotter code, so this
@@ -50,6 +52,29 @@ pub extern "C" fn ddtrace_heap_gotter_is_installed() -> bool {
5052
libdd_profiling_heap_gotter::heap_overrides_are_installed()
5153
}
5254

55+
/// Report whether this cdylib was built with live-heap tracking (Phase 2:
56+
/// the `ddheap:free` USDT + per-allocation retain flagging), so the free side
57+
/// can be reconciled against allocations. This is a *compile-time* property of
58+
/// the shipped artifact — alloc-only builds return `false`, live-heap builds
59+
/// return `true` — and is NOT a runtime toggle. The Python activator surfaces
60+
/// it so the profiler can report which native-heap mode is armed.
61+
///
62+
/// AIDEV-NOTE: `cfg!(feature = "live-heap")` here is our crate's own feature,
63+
/// declared in Cargo.toml as a pure forward to
64+
/// `libdd-profiling-heap-gotter/live-heap`. It is true iff the artifact was
65+
/// built with `--features live-heap`, which is exactly the condition under
66+
/// which the sampler compiled in DD_HEAP_LIVE_TRACKING and emits `ddheap:free`.
67+
/// Keep this boolean in lockstep with the Cargo feature so the runtime signal
68+
/// never disagrees with the ELF `.note.stapsdt` reality.
69+
///
70+
/// # Safety
71+
///
72+
/// C ABI entry point with no arguments and no pointers; always safe to call.
73+
#[no_mangle]
74+
pub extern "C" fn ddtrace_heap_gotter_live_heap_enabled() -> bool {
75+
cfg!(feature = "live-heap")
76+
}
77+
5378
/// Test-only: number of times a patched hook has run in this process. Lets
5479
/// integration tests prove the patched GOT was actually exercised without a
5580
/// live eBPF attach. Only present when built with the `test-support` feature;

tests/profiling/test_native_heap_gotter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ def test_native_heap_gotter_smoke() -> None:
3030
# Wheel built without the gotter cdylib: strictly a no-op.
3131
assert heap_gotter.install() is False
3232
assert heap_gotter.is_installed() is False
33+
# live_heap_enabled must be a safe False no-op when the cdylib is absent.
34+
assert heap_gotter.live_heap_enabled() is False
3335
else:
3436
# Native-heap build: arming must take effect and be idempotent.
3537
assert heap_gotter.is_installed() is False
3638
assert heap_gotter.install() is True
3739
assert heap_gotter.is_installed() is True
3840
assert heap_gotter.install() is True
3941

42+
# live-heap is a compile-time property; the query must return a bool and
43+
# never crash regardless of whether this build enabled the feature.
44+
assert isinstance(heap_gotter.live_heap_enabled(), bool)
45+
4046
# Generate allocation pressure; this must not crash with the patched GOT.
4147
blobs: list[tuple[str, int]] = []
4248
for i in range(200):

0 commit comments

Comments
 (0)