Skip to content

Commit b1eace6

Browse files
rkennkeclaude
andauthored
feat(nativemem): categorized native-memory accounting — first cut (#669)
* feat(nativemem): categorized native-memory accounting — first cut Add a NativeMem facility that tracks the profiler's own native memory usage per category, with a moving-window average and a running peak. - NativeMemCategory enum whose per-category live gauges partition the total (each backing allocation belongs to exactly one category, so there is no double counting). - Live accounting is always-on and independent of the COUNTERS build flag: record() is a single relaxed atomic add, async-signal-safe and usable from signal handlers. - sample() folds the live gauges into a moving-window average and a high-water max; it is ticked once per JFR chunk finish. - Totals mirror into the existing NATIVE_MEM_{LIVE,AVG,MAX}_BYTES counters (JFR + JNI counter path); per-category values are emitted as native_mem_{live,avg,max}_bytes.<category> counter events, reusing the existing counter event format (no new event type). Instrumented sites (tagged CALLTRACE, their sole use today): the LinearAllocator chunk alloc/free (the call-trace arena) and the per-shard calltrace buffers. Accounting lives with the semantic owner rather than OS::safeAlloc, which stays category-agnostic. First-cut limits, documented in code: only CALLTRACE sites are instrumented so far (other categories read 0 until tagged); the max is sampled rather than spike-accurate; transient negative live is clamped to 0. The existing reserved/used/waste counters (CALLTRACE_STORAGE_BYTES, DICTIONARY_ARENA_WASTE_BYTES) remain an independent nested dimension and are intentionally not summed into the per-category total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nativemem): precise per-category max with bounded total Track each category's peak at allocation time instead of sampling it at chunk finish, so a spike that rises and falls between two ticks is still captured. record() updates the per-category high-water mark on positive deltas via a relaxed CAS: the common (no new peak) path is a single load plus a compare, and the CAS fires only when a genuinely higher peak is set, which is rare since the peak is monotonic. Frees skip the check. The total peak avoids a shared global counter (a contention hotspot on a single cache line hit by every allocation) and is instead reported as a bracket: - NATIVE_MEM_MAX_BYTES = upper bound = sum of the precise per-category peaks (exact when the peaks coincide, otherwise an overestimate). - native_mem_max_observed_total_bytes = lower bound = the largest instantaneous total seen at a sampling tick. sample() no longer touches the per-category peaks; it only refreshes the moving averages and the observed total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nativemem): tag the big native-memory consumers Extend per-category accounting beyond CALLTRACE to the other large, cleanly-paired backing allocations: - THREAD_LOCAL: `new`/`delete ProfiledThread` (sizeof, per thread). - JFR_BUFFERS: `new`/`delete Recording` (embeds the RecordingBuffer array and cpu-monitor buffer). - LINE_TABLES: the malloc'd JVMTI line-number table copy, tracked beside the existing LINE_NUMBER_TABLES counter and freed in ~SharedLineNumberTable (byte size recovered from the stored entry count). - PERF: the perf ring mmap (2 * page_size), paired with its munmap. - THREAD_FILTER: ChunkStorage chunks (bounded, tagged only on successful CAS install) and the FreeListNode array. - CODECACHE: a recomputed gauge, mirrored via the new NativeMem::setLive() at the existing CodeCache size-set site. setLive() overwrites live and still advances the peak. Not tagged in this commit: - CONTEXT has no separate allocation — the OTel context record is embedded in ProfiledThread, so it is already counted under THREAD_LOCAL; tagging it again would double-count. The category stays 0 by design. - DICTIONARY is deferred to its own commit: it has two implementations (the older per-key-malloc Dictionary used for symbols/packages, with set()-based counters and bulk key-frees that lose per-item size, and the arena-based StringDictionary where keys live inside chunks). A partial number that tagged only one would mislead, so it needs dedicated per-implementation handling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nativemem): tag DICTIONARY, remove CONTEXT Add DICTIONARY to the per-category accounting, and remove the CONTEXT category. CONTEXT had no separate backing allocation — the OTel context record is embedded in ProfiledThread and already counted under THREAD_LOCAL — so it would have been a permanently-zero line. Removed rather than left dangling. DICTIONARY is tagged across both implementations, counting physical backing allocations once each (single category; per-role breakdown, if ever wanted, belongs in a nested dimension, not extra top-level categories): - Dictionary (older, per-key malloc; used for symbols/packages): root and overflow DictTables (constant size) and key strings. Key frees are bulk and lose per-item sizes, but keys are null-terminated so the malloc'd size (strlen + 1) is recovered at free — no running total needed. - StringDictionary / StringArena (arena-based): arena chunks and root / overflow SBTables. Keys are bump-allocated inside chunks, so they are NOT counted separately (that would double-count). Chunk and SBTable accounting is unconditional, independent of the diagnostic counters' _counter_offset gate, so anonymous dictionaries are covered too. Tests: lifecycle invariants for both implementations assert the accounting grows on insert, returns exactly to the construction baseline after clear(), and to zero after destruction — directly proving inc/dec pairing (including the strlen-at-free path and arena chunk growth). Full gtestDebug suite green (356 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(nativemem): scope the async-signal-safety note to CALLTRACE The class comment implied every record() call needs to be async-signal-safe. In fact most categories allocate via malloc/new off the signal path, where the property is irrelevant. Only the CALLTRACE arena allocates from within the sampling signal handler (via OS::safeAlloc's raw mmap syscall), so that is where record() staying a relaxed atomic add actually matters. Reword accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(nativemem): rename CODECACHE category to NATIVE_SYMBOLS The "CodeCache" name is async-profiler's, and collides with the JVM's JIT code cache. This category measures something unrelated: the profiler's own per-native-library symbol tables (used to symbolicate native frames), not JVM-managed code. Rename the NativeMem category and its JFR label to native_symbols so the metric is unambiguous. The existing CODECACHE_NATIVE_SIZE_BYTES counter keeps its name for continuity; only the new NativeMem category is renamed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(nativemem): correct record() description (add + high-water CAS) The class comment still described record() as "a single relaxed atomic add" from before precise per-category max was added. record() now also does a conditional lock-free high-water update on allocation. Correct the wording; the async-signal-safety guarantee still holds (lock-free atomics). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(nativemem): assert the non-negative and key-length invariants Two invariants the accounting relies on are now asserted (stripped under NDEBUG, so no release cost and never in a real signal handler): - Per-category live bytes never go negative: record() asserts the post-update value >= 0. A negative means an unbalanced/oversized free. This lets liveTotal() sum without clamping, since each term is >= 0. - Dictionary keys are NUL-free strings of exactly `length`: allocateKey() asserts strlen == length. The NM_DICTIONARY free path recovers a key's size via strlen at clear(), so an embedded NUL would under-count; the assert trips in debug/gtest instead of silently drifting. The sample() clamp is retained as a release-mode safety net for the asserted-impossible negative case, and its comment updated to say so. The old NegativeLiveClampedInSample test is removed: it deliberately drove a category negative, which now (correctly) trips the record() assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nativemem): clamp emitted values; fix JFR_BUFFERS decrement ordering Review fixes: - Clamp per-category live to 0 at the emit boundary (liveTotal() and writeNativeMem()). These values are serialized via putVar64(u64); a negative gauge would otherwise emit a huge varint and corrupt the counter stream. Matches the clamping sample() already does. The record() invariant asserts non-negative in debug; this guards the release path where the assert is stripped. - Move the NM_JFR_BUFFERS decrement in FlightRecorder::stop() to after `delete rec`: ~Recording() runs finishChunk(), which emits the counters for the final chunk while the buffers are still live, so account the free only once it has happened. - Reword the "lower bound" description of the observed total: the sampled per-category sum is not an atomic snapshot, so it can drift above or below a true instantaneous total; it is an approximate sampled figure, and maxTotal() remains the authoritative ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(profiler): two-phase calltrace resize; refresh native-lib counters on stop Review fixes: - Reallocate the per-shard calltrace buffers in two phases: allocate all CONCURRENCY_LEVEL replacements first, and only swap/free/account if all succeed. A mid-loop allocation failure now leaves the profiler entirely unchanged (old buffers, _max_stack_depth, and NM_CALLTRACE accounting stay consistent), instead of the previous partial-swap + reset of _max_stack_depth to 0 that mis-accounted a later resize. - Extract updateNativeLibMemStats(): read native_libs.memoryUsage() once (was called three times) and publish the CODECACHE counters plus the NM_NATIVE_SYMBOLS gauge. Call it from both dump() and stop() (before _jfr.stop()), so the final chunk reflects native-symbol memory even when stopping without a preceding dump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nativemem): account the perf _events array under NM_PERF The per-thread PerfEvent array (max_events * sizeof(PerfEvent)) is real perf-engine memory that NM_PERF omitted — it counted only the ring mappings. Add paired inc/dec around the calloc/free in start(), guarded on non-null so a prior calloc failure is not mis-accounted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nativemem): THREAD_LOCAL lifecycle coverage; copyright headers - Add two tests proving NM_THREAD_LOCAL balances across the ProfiledThread lifecycle: the decrement lives in freeValue(), reached both via release() -> ThreadLocal::clear() and via the pthread-key destructor on thread exit. Both paths return the gauge to baseline. - dictionary_ut.cpp: update copyright to 2025, 2026. - stringDictionary_ut.cpp: add the missing Datadog copyright header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nativemem): balance deleteForTest; account frees after they happen Two follow-up review fixes: - ProfiledThread::deleteForTest() (UNIT_TEST helper) deleted the object directly, bypassing freeValue()'s NM_THREAD_LOCAL decrement and leaking live bytes across tests that use it. It stands in for freeValue()'s delete, so mirror the decrement. - In ~ThreadFilter(), record the decrements after the memory is actually freed: delete the chunk before its decrement, and reset the _free_list unique_ptr explicitly before recording (rather than letting it free the array after the destructor body runs). Keeps the gauge from leading the free during teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(nativemem): record calltrace-buffer decrement after free(prev) Consistency: the two-phase resize recorded the NM_CALLTRACE decrement before free(prev); the other decrement sites (FlightRecorder::stop, ~ThreadFilter) account the free after it happens. Reorder to match. Functionally equivalent (free doesn't read the counter), purely for uniformity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(nativemem): record perf _events decrement after free() Consistency (same as the calltrace-buffer and ~ThreadFilter sites): record the NM_PERF decrement for the old _events array after free(_events) rather than before. Capture the old size first, since _max_events is overwritten by the reallocation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(nativemem): record THREAD_LOCAL decrement after delete pt Consistency with the other decrement sites (calltrace resize, perf _events, ~ThreadFilter): record the NM_THREAD_LOCAL decrement after delete pt rather than before. sizeof is a compile-time constant, so no value is lost by deleting first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(nativemem): record dictionary/arena decrements after free() Sweep the remaining "record before free" decrement sites to record after the free, consistent with the calltrace/perf/thread-local/ThreadFilter sites (avoids a transient underreport if sampling races teardown): - StringArena chunk frees (~StringArena, reset()). - SBTable frees (freeOverflowNodes, ~StringDictionaryBuffer). - Dictionary key strings (capture strlen+1 before free, record after) and overflow DictTable frees. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(nativemem): smoke-test the aggregate NM accounting counters Add an end-to-end smoke test asserting the always-on native-memory accounting counters (native_mem_live_bytes / _avg_bytes / _max_bytes) are emitted into the recording after a short profiling run and hold their sanity invariants: the peak total (sum of precise per-category peaks) brackets both the live total and the moving-window average. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(nativemem): use the short-form license header Replace the full Apache-2.0 boilerplate in nativeMem.{h,cpp} and nativeMem_ut.cpp with the short-form copyright + SPDX identifier used across the tree, and normalize the dictionary_ut.cpp copyright line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c57ba18 commit b1eace6

20 files changed

Lines changed: 757 additions & 35 deletions

ddprof-lib/src/main/cpp/counters.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
X(CALLTRACE_STORAGE_TRACES, "calltrace_storage_traces") \
5454
X(LINEAR_ALLOCATOR_BYTES, "linear_allocator_bytes") \
5555
X(LINEAR_ALLOCATOR_CHUNKS, "linear_allocator_chunks") \
56+
X(NATIVE_MEM_LIVE_BYTES, "native_mem_live_bytes") \
57+
X(NATIVE_MEM_MAX_BYTES, "native_mem_max_bytes") \
58+
X(NATIVE_MEM_AVG_BYTES, "native_mem_avg_bytes") \
5659
X(THREAD_IDS_COUNT, "thread_ids_count") \
5760
X(THREAD_NAMES_COUNT, "thread_names_count") \
5861
X(THREAD_FILTER_PAGES, "thread_filter_pages") \

ddprof-lib/src/main/cpp/dictionary.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "arch.h"
1919
#include "counters.h"
2020
#include "signalSafety.h"
21+
#include <cassert>
2122
#include <climits>
2223
#include <stdlib.h>
2324
#include <string.h>
@@ -26,6 +27,12 @@ static inline char *allocateKey(const char *key, size_t length) {
2627
char *result = (char *)malloc(length + 1);
2728
memcpy(result, key, length);
2829
result[length] = 0;
30+
// NM_DICTIONARY accounting recovers a freed key's size via strlen at clear()
31+
// time, which requires the key to be a NUL-free string of exactly `length`.
32+
// Pin that assumption here so a future caller passing an embedded NUL trips in
33+
// debug/gtest rather than silently under-counting the free. Stripped under
34+
// NDEBUG.
35+
assert(strlen(result) == length);
2936
return result;
3037
}
3138

@@ -37,6 +44,7 @@ static inline bool keyEquals(const char *candidate, const char *key,
3744
Dictionary::~Dictionary() {
3845
clear(_table, _id);
3946
free(_table);
47+
NativeMem::record(NM_DICTIONARY, -(long long)sizeof(DictTable));
4048
Counters::set(DICTIONARY_BYTES, 0, _id);
4149
Counters::set(DICTIONARY_PAGES, 0, _id);
4250
}
@@ -58,14 +66,20 @@ void Dictionary::clear(DictTable *table, int id) {
5866
DictRow *row = &table->rows[i];
5967
for (int j = 0; j < CELLS; j++) {
6068
if (row->keys[j]) {
69+
// Keys are null-terminated, so the malloc'd size (length + 1) is
70+
// recoverable without tracking it per key. Capture it before free(),
71+
// then record after, consistent with the other decrement sites.
72+
long long key_bytes = (long long)(strlen(row->keys[j]) + 1);
6173
free(row->keys[j]); // content is zeroed en-mass in the clear() function
74+
NativeMem::record(NM_DICTIONARY, -key_bytes);
6275
}
6376
}
6477
if (row->next != NULL) {
6578
clear(row->next, id);
6679
DictTable *tmp = row->next;
6780
row->next = NULL;
6881
free(tmp);
82+
NativeMem::record(NM_DICTIONARY, -(long long)sizeof(DictTable));
6983
}
7084
}
7185
}
@@ -110,6 +124,7 @@ unsigned int Dictionary::lookup(const char *key, size_t length, bool for_insert,
110124
if (__sync_bool_compare_and_swap(&row->keys[c], NULL, new_key)) {
111125
Counters::increment(DICTIONARY_KEYS, 1, _id);
112126
Counters::increment(DICTIONARY_KEYS_BYTES, length + 1, _id);
127+
NativeMem::record(NM_DICTIONARY, (long long)(length + 1));
113128
atomicInc(_size);
114129
return table->index(h % ROWS, c);
115130
}
@@ -130,6 +145,7 @@ unsigned int Dictionary::lookup(const char *key, size_t length, bool for_insert,
130145
} else {
131146
Counters::increment(DICTIONARY_PAGES, 1, _id);
132147
Counters::increment(DICTIONARY_BYTES, sizeof(DictTable), _id);
148+
NativeMem::record(NM_DICTIONARY, (long long)sizeof(DictTable));
133149
}
134150
} else {
135151
return sentinel;

ddprof-lib/src/main/cpp/dictionary.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#define _DICTIONARY_H
1919

2020
#include "counters.h"
21+
#include "nativeMem.h"
2122
#include <map>
2223
#include <stddef.h>
2324
#include <stdlib.h>
@@ -67,6 +68,7 @@ class Dictionary {
6768
_table = (DictTable *)calloc(1, sizeof(DictTable));
6869
Counters::set(DICTIONARY_PAGES, 1, id);
6970
Counters::set(DICTIONARY_BYTES, sizeof(DictTable), id);
71+
NativeMem::record(NM_DICTIONARY, (long long)sizeof(DictTable));
7072
_table->base_index = _base_index = 1;
7173
_size = 0;
7274
}

ddprof-lib/src/main/cpp/flightRecorder.cpp

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "context.h"
1313
#include "context_api.h"
1414
#include "counters.h"
15+
#include "nativeMem.h"
1516
#include "dictionary.h"
1617
#include "flightRecorder.inline.h"
1718
#include "incbin.h"
@@ -73,6 +74,10 @@ SharedLineNumberTable::~SharedLineNumberTable() {
7374
if (_ptr != nullptr) {
7475
free(_ptr);
7576
Counters::decrement(LINE_NUMBER_TABLES);
77+
// _size is the JVMTI entry count passed at construction (see
78+
// fillJavaMethodInfo), so the byte size matches the allocation.
79+
NativeMem::record(NM_LINE_TABLES, -(long long)((size_t)_size *
80+
sizeof(jvmtiLineNumberEntry)));
7681
}
7782
}
7883

@@ -420,6 +425,8 @@ void Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method,
420425
line_number_table_size, owned_table);
421426
// Increment counter for tracking live line number tables
422427
Counters::increment(LINE_NUMBER_TABLES);
428+
NativeMem::record(NM_LINE_TABLES, (long long)((size_t)line_number_table_size *
429+
sizeof(jvmtiLineNumberEntry)));
423430
}
424431
}
425432

@@ -810,7 +817,9 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) {
810817
// dictionary) will reflect the previous serialization. That is, some level of
811818
// familiarity with the code base will be required to use this diagnostic
812819
// information for now.
820+
updateNativeMemStats();
813821
writeCounters(_buf);
822+
writeNativeMem(_buf);
814823

815824
// Keep a simple stats for where we failed to unwind
816825
// For the sakes of simplicity we are not keeping the count of failed unwinds which would also be
@@ -1776,6 +1785,69 @@ void Recording::writeLogLevels(Buffer *buf) {
17761785
}
17771786
}
17781787

1788+
void Recording::updateNativeMemStats() {
1789+
// Refresh the moving-window averages and the observed total peak. Per-category
1790+
// peaks are maintained precisely at allocation time, so they are not sampled
1791+
// here; the total peak is bracketed instead (see writeNativeMem).
1792+
NativeMem::sample();
1793+
1794+
// Mirror the totals into the flat counter table so they flow out through the
1795+
// existing counter path (JFR T_DATADOG_COUNTER events and the JNI debug
1796+
// counters). NATIVE_MEM_MAX_BYTES carries the upper bound on the total peak
1797+
// (sum of precise per-category peaks); the observed sampled total and the
1798+
// per-category values are emitted by writeNativeMem().
1799+
Counters::set(NATIVE_MEM_LIVE_BYTES, NativeMem::liveTotal());
1800+
Counters::set(NATIVE_MEM_AVG_BYTES, NativeMem::avgTotal());
1801+
Counters::set(NATIVE_MEM_MAX_BYTES, NativeMem::maxTotal());
1802+
}
1803+
1804+
void Recording::writeNativeMem(Buffer *buf) {
1805+
// Emit native-memory stats as counter events, reusing the counter event format
1806+
// so they land alongside the totals without needing a dedicated event type or
1807+
// a slot in the counter table.
1808+
auto emit = [&](const char *label, long long value) {
1809+
// Clamp to 0 before encoding: the value is serialized as an unsigned varint
1810+
// (putVar64), so a negative live gauge would emit a huge value and corrupt
1811+
// the counter stream. avg/max are already non-negative; live is clamped
1812+
// here to match sample()/liveTotal().
1813+
if (value < 0) {
1814+
value = 0;
1815+
}
1816+
int start = buf->skip(1);
1817+
buf->putVar64(T_DATADOG_COUNTER);
1818+
buf->putVar64(_start_ticks);
1819+
buf->putUtf8(label);
1820+
buf->putVar64(value);
1821+
writeEventSizePrefix(buf, start);
1822+
flushIfNeeded(buf);
1823+
};
1824+
1825+
// Per-category live/avg/max, named "<metric>.<category>". The max here is the
1826+
// precise per-category peak tracked at allocation time.
1827+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
1828+
NativeMemCategory cat = (NativeMemCategory)c;
1829+
const char *name = NativeMem::categoryName(cat);
1830+
const struct {
1831+
const char *prefix;
1832+
long long value;
1833+
} metrics[] = {
1834+
{"native_mem_live_bytes.", NativeMem::live(cat)},
1835+
{"native_mem_avg_bytes.", NativeMem::avg(cat)},
1836+
{"native_mem_max_bytes.", NativeMem::max(cat)},
1837+
};
1838+
for (const auto &m : metrics) {
1839+
char label[64];
1840+
snprintf(label, sizeof(label), "%s%s", m.prefix, name);
1841+
emit(label, m.value);
1842+
}
1843+
}
1844+
1845+
// NATIVE_MEM_MAX_BYTES already carries the upper bound on the total peak (sum
1846+
// of precise per-category peaks); here we also emit the largest observed
1847+
// sampled total (a non-atomic per-category sum; approximate).
1848+
emit("native_mem_max_observed_total_bytes", NativeMem::maxTotalObserved());
1849+
}
1850+
17791851
void Recording::writeCounters(Buffer *buf) {
17801852
long long *counters = Counters::getCounters();
17811853
if (counters) {
@@ -2064,6 +2136,9 @@ Error FlightRecorder::newRecording(bool reset) {
20642136
}
20652137

20662138
_rec = new Recording(fd, _args);
2139+
// The Recording embeds the JFR RecordingBuffer array and the cpu-monitor
2140+
// buffer, so its allocation size is the profiler's JFR buffer footprint.
2141+
NativeMem::record(NM_JFR_BUFFERS, (long long)sizeof(Recording));
20672142
return Error::OK;
20682143
}
20692144

@@ -2074,7 +2149,12 @@ void FlightRecorder::stop() {
20742149
if (rec != nullptr) {
20752150
// NULL first, deallocate later
20762151
_rec = nullptr;
2152+
// Decrement AFTER delete: ~Recording() runs finishChunk(), which emits the
2153+
// native-memory counters for the final chunk. The Recording buffers are
2154+
// still live during that serialization, so account the free only once it
2155+
// has actually happened.
20772156
delete rec;
2157+
NativeMem::record(NM_JFR_BUFFERS, -(long long)sizeof(Recording));
20782158
}
20792159
}
20802160

ddprof-lib/src/main/cpp/flightRecorder.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ class Recording {
304304

305305
void writeCounters(Buffer *buf);
306306

307+
void updateNativeMemStats();
308+
void writeNativeMem(Buffer *buf);
309+
307310
void writeUnwindFailures(Buffer *buf);
308311

309312
void writeContextSnapshot(Buffer *buf, Context &context);

ddprof-lib/src/main/cpp/linearAllocator.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
#include "linearAllocator.h"
1919
#include "counters.h"
20+
#include "nativeMem.h"
2021
#include "os.h"
2122
#include "common.h"
2223
#include <stdio.h>
@@ -167,6 +168,9 @@ void LinearAllocator::freeChunks(ChunkList& chunks) {
167168
OS::safeFree(current, chunks.chunk_size);
168169
Counters::decrement(LINEAR_ALLOCATOR_BYTES, chunks.chunk_size);
169170
Counters::decrement(LINEAR_ALLOCATOR_CHUNKS);
171+
// The LinearAllocator's only user is call-trace storage, so all of its
172+
// chunk memory is attributed to the CALLTRACE category.
173+
NativeMem::record(NM_CALLTRACE, -(long long)chunks.chunk_size);
170174
current = prev;
171175
}
172176

@@ -260,6 +264,7 @@ Chunk *LinearAllocator::allocateChunk(Chunk *current) {
260264

261265
Counters::increment(LINEAR_ALLOCATOR_BYTES, _chunk_size);
262266
Counters::increment(LINEAR_ALLOCATOR_CHUNKS);
267+
NativeMem::record(NM_CALLTRACE, (long long)_chunk_size);
263268
}
264269
return chunk;
265270
}
@@ -275,6 +280,7 @@ void LinearAllocator::freeChunk(Chunk *current) {
275280
OS::safeFree(current, _chunk_size);
276281
Counters::decrement(LINEAR_ALLOCATOR_BYTES, _chunk_size);
277282
Counters::decrement(LINEAR_ALLOCATOR_CHUNKS);
283+
NativeMem::record(NM_CALLTRACE, -(long long)_chunk_size);
278284
}
279285

280286
void LinearAllocator::reserveChunk(Chunk *current) {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2026, Datadog, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
#include "nativeMem.h"
6+
7+
volatile long long NativeMem::_live[NM_NUM_CATEGORIES] = {};
8+
volatile long long NativeMem::_max[NM_NUM_CATEGORIES] = {};
9+
long long NativeMem::_window[NM_NUM_CATEGORIES][NativeMem::WINDOW] = {};
10+
long long NativeMem::_total_window[NativeMem::WINDOW] = {};
11+
int NativeMem::_window_pos = 0;
12+
int NativeMem::_window_count = 0;
13+
long long NativeMem::_avg[NM_NUM_CATEGORIES] = {};
14+
long long NativeMem::_total_avg = 0;
15+
long long NativeMem::_total_max_observed = 0;
16+
17+
long long NativeMem::liveTotal() {
18+
long long total = 0;
19+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
20+
// Clamp per-category negatives to 0 (see sample()): the total is exported
21+
// as an unsigned varint, so a stray negative would otherwise serialize as a
22+
// huge value and corrupt the counter stream.
23+
long long v = load(_live[c]);
24+
if (v > 0) {
25+
total += v;
26+
}
27+
}
28+
return total;
29+
}
30+
31+
long long NativeMem::maxTotal() {
32+
long long total = 0;
33+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
34+
total += load(_max[c]);
35+
}
36+
return total;
37+
}
38+
39+
void NativeMem::sample() {
40+
long long total = 0;
41+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
42+
long long v = load(_live[c]);
43+
// A category's live bytes are never negative under correct pairing (asserted
44+
// in record()). This clamp is a release-mode safety net: should an accounting
45+
// bug slip past the assert under NDEBUG, it keeps a negative from skewing the
46+
// window average and total rather than propagating garbage.
47+
if (v < 0) {
48+
v = 0;
49+
}
50+
_window[c][_window_pos] = v;
51+
total += v;
52+
}
53+
54+
// The per-category peaks are maintained precisely at allocation time by
55+
// record(); here we only track the largest observed total. Note `total` is a
56+
// non-atomic sum of the per-category gauges read moments apart, so it is an
57+
// approximate sampled figure, not a strict instantaneous total.
58+
_total_window[_window_pos] = total;
59+
if (total > _total_max_observed) {
60+
_total_max_observed = total;
61+
}
62+
63+
_window_pos = (_window_pos + 1) % WINDOW;
64+
if (_window_count < WINDOW) {
65+
_window_count++;
66+
}
67+
68+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
69+
long long sum = 0;
70+
for (int i = 0; i < _window_count; i++) {
71+
sum += _window[c][i];
72+
}
73+
_avg[c] = sum / _window_count;
74+
}
75+
76+
long long total_sum = 0;
77+
for (int i = 0; i < _window_count; i++) {
78+
total_sum += _total_window[i];
79+
}
80+
_total_avg = total_sum / _window_count;
81+
}
82+
83+
void NativeMem::reset() {
84+
for (int c = 0; c < NM_NUM_CATEGORIES; c++) {
85+
store(_live[c], (long long)0);
86+
store(_max[c], (long long)0);
87+
_avg[c] = 0;
88+
for (int i = 0; i < WINDOW; i++) {
89+
_window[c][i] = 0;
90+
}
91+
}
92+
for (int i = 0; i < WINDOW; i++) {
93+
_total_window[i] = 0;
94+
}
95+
_window_pos = 0;
96+
_window_count = 0;
97+
_total_avg = 0;
98+
_total_max_observed = 0;
99+
}
100+
101+
const char *NativeMem::categoryName(NativeMemCategory category) {
102+
#define X_NM_NAME(a, b) b,
103+
static const char *const names[] = {DD_NATIVE_MEM_CATEGORY_TABLE(X_NM_NAME)};
104+
#undef X_NM_NAME
105+
if (category < 0 || category >= NM_NUM_CATEGORIES) {
106+
return "unknown";
107+
}
108+
return names[category];
109+
}

0 commit comments

Comments
 (0)