feat(nativemem): measure allocator overhead instead of assuming a blanket factor - #759
feat(nativemem): measure allocator overhead instead of assuming a blanket factor#759rkennke wants to merge 3 commits into
Conversation
…accounting
Adds five counters sampled from mallinfo2() on the JFR flush path:
malloc_arena_bytes, malloc_in_use_bytes, malloc_free_held_bytes,
malloc_trimmable_bytes, malloc_mmap_bytes.
Motivation: memory reconciliation against process RSS currently applies a x1.17
"chunk overhead" factor and treats free-but-held arena pages as an unmeasured
residual term, both borrowed from a different workload. Both are properties of
the ALLOCATOR, not of the profiler -- under tcmalloc or jemalloc they behave
differently -- so they belong in reported counters rather than folded into a
"profiler cost" figure or estimated by analogy.
These are deliberately NOT NM_* categories. nativeMem.h documents that the
per-category gauges partition the profiler's own allocations
(sum(category) == total, no double counting); free-but-held arena pages are
mostly other subsystems' chunks stranded by interleaving and cannot be
attributed to any profiler allocation, so adding them as a category would break
that invariant. Reported separately, they bracket the profiler's contribution:
lower bound = the per-category counters, upper bound = those plus process-wide
free-but-held.
keepcost is reported separately from fordblks because it distinguishes glibc's
trim-threshold retention (reclaimable by malloc_trim) from genuine fragmentation
(not reclaimable).
Flush path only: mallinfo2() walks every arena taking each arena lock, so it is
neither cheap nor async-signal-safe and must never be reached from the sampling
signal handler. Once per JFR chunk is negligible. Guarded to glibc 2.33+; the
older int-based mallinfo() silently truncates past 2 GiB so it is not a usable
fallback, and the counters simply read zero on musl and macOS.
The tests validate the premise rather than the plumbing, and correct an
assumption I held while writing them:
- Contiguous churn of the shape a real flush produces (26,450 blocks of
6,144 B = sizeof(SBTable), from StringDictionaryBuffer::insert_with_id)
allocated and then all freed is FULLY RETURNED to the OS -- arena 163 MB
back down to 905 KB. Churn alone strands nothing.
- Stranding requires live allocations INTERLEAVED among the freed ones, which
pin the region so free chunks cannot coalesce to the arena top. Measured:
117.5 MiB retained, of which malloc_trim reclaims 0.03 MiB.
That distinction matters for interpreting flush-time memory steps: a burst is
not self-evidently a stranding mechanism, and whether it strands depends on what
else is live at the time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… assuming it
Adds native_mem_chunk_overhead_bytes.<category>: the rounding-plus-header cost
that RSS pays on top of the logical bytes the NM_* gauges record. Measured from
malloc_usable_size() at each allocation, kept in a separate gauge so
native_mem_live_bytes stays directly comparable to sizeof() arithmetic.
This replaces a blanket x1.17 multiplier applied during RSS reconciliation. That
factor was measured on one workload (~650,000 live allocations averaging ~83 B)
and then applied to categories with completely different size mixes. Overhead is
a function of PER-ALLOCATION size, so no single factor can serve them:
96 B (MethodMap node) overhead 16 B 16.67 % <- x1.17 is right here
6 KB (SBTable) overhead 16 B 0.26 %
512 KB (string arena) overhead 16 B 0.003 % (arena-served)
2 MB (large buffer) overhead 4088 B 0.19 %
Charging the 512 KB string-arena chunks and the JFR buffers 17 % over-credits
the explained total by over 1 MiB against a residual of only a few MiB.
The header size is probed at first use rather than hardcoded, because the
allocator in force is not knowable at compile time: an LD_PRELOAD'd tcmalloc or
jemalloc adds no per-object header while __GLIBC__ stays defined, so assuming
glibc's 8 bytes would invent overhead that does not exist. The probe allocates
same-size blocks and takes the smallest positive address stride (stride =
usable + header); implausible results fall back to 0, reporting only the
rounding we can see rather than guessing.
Wired into CountingAllocator (all STL-node categories) and StringArena's chunk
alloc/free. Call sites still using plain record() contribute zero here, which is
why the gauge is additive rather than a correction to the live figure --
notably NM_NATIVE_SYMBOLS, which is a computed sum of logical sizes with no
pointer available at record time and needs a different approach.
Two findings from the tests, both correcting assumptions I had made:
- The 512 KB chunk does NOT pay a negligible fixed 16 bytes as predicted. The
identical request measures 4088 bytes of overhead in one allocator state and
16 in another, because glibc's mmap threshold is DYNAMIC and rises as large
blocks are freed. Large-allocation overhead is therefore not a function of
request size at all, which rules out any arithmetic formula -- including the
align16(S + 8) approach originally proposed -- and settles that it must be
measured.
- Sanitizer builds substitute an allocator that reports no usable-size slack
and defeats the header probe, so overhead reads 0. That is the honest answer
for such an allocator, so the magnitude assertions skip rather than fail.
Release, debug, ASan and TSan all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extends the measured chunk-overhead accounting to the largest category, which the previous change could not cover. NM_NATIVE_SYMBOLS is a gauge -- setLive(native_libs.memoryUsage()) -- computed as a sum of logical sizes, so there is no pointer in hand at record time and recordAlloc() does not apply. CodeCache::memoryUsage() already walks every symbol name, so it now optionally accumulates the measured overhead in that same pass via an out-param, and Profiler publishes it with a new gauge-style NativeMem::setOverhead(). Single pass, no extra walk. NativeFunc::nameOverhead() is where the measurement lives because only NativeFunc knows the real allocation base: `name` points *into* the block at offset sizeof(NativeFunc), so querying the allocator with `name` itself would be undefined. It sits next to allocSize(), which the header already documents as the single source of truth for that allocation's size. The _blobs array is deliberately excluded: one allocation per library, large enough that its overhead is a rounding error, and it comes from new CodeBlob[] whose returned pointer is not guaranteed to be the allocator's block base. Excluding it understates by a negligible amount rather than risking an unsound reading. Measured on a real profiled JVM (classes sweep, 60k classes): category live MiB overhead MiB overhead % calltrace 24.534 0.0000 0.00 (mmap-backed) native_symbols 11.882 0.9796 8.24 dictionary 4.553 0.0350 0.77 thread_info 0.003 0.0004 13.97 native_symbols measures 8.24 %, roughly half the 17 % the blanket factor assumed, and it is the largest category -- so this is where that factor did the most damage. Across the malloc-backed categories the blanket x1.17 would charge about 3.0 MiB where 1.0 MiB is measured, over-crediting the explained total by ~2 MiB against a residual of only a few MiB. Correcting it therefore WIDENS the reconciliation gap, which is the direction predicted. Coverage is still partial and the measured figure is a lower bound: jfr_buffers, liveness and line_tables record through plain record() and contribute zero here. calltrace's zero is correct rather than missing -- it is mmap-backed and pays no malloc chunk overhead at all. Release, debug, ASan and TSan pass, including codeCache_ut and libraries_ut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scan-Build Report
Bug Summary
Reports
|
||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ec7bfbd2b9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| void Recording::updateMallocArenaStats() { | ||
| #ifdef DD_HAVE_MALLINFO2 | ||
| struct mallinfo2 mi = mallinfo2(); |
There was a problem hiding this comment.
Avoid glibc stats when a replacement allocator is active
When the process uses LD_PRELOAD with tcmalloc or jemalloc—as the repository's reliability jobs do—__GLIBC__ remains defined, so this branch still calls glibc's mallinfo2(). That function reports glibc's internal arenas rather than allocations redirected to the replacement allocator, causing the five new process-wide counters to contain zero or unrelated glibc state precisely during allocator-comparison experiments. Detect the active allocator and use its statistics API, or mark these counters unavailable outside glibc malloc.
Useful? React with 👍 / 👎.
|
🔄 Datadog auto-retried 1 job - 1 passed on retry 🔗 Commit SHA: ec7bfbd | Docs | View more details | Give us feedback! |
CI Test ResultsRun: #33169229844 | Commit:
Status Overview
Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled Failed Testsglibc-amd64/debug / 11Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 8-j9Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 8-ibmJob: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 17-j9Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 11-j9Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 25-graalJob: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 17Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 25Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 8-orclJob: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 21Job: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 17-graalJob: View logs No detailed failure information available. Check the job logs. glibc-amd64/debug / 21-graalJob: View logs No detailed failure information available. Check the job logs. Summary: Total: 32 | Passed: 19 | Failed: 13 Updated: 2026-08-28 12:17:37 UTC |
Re-ran the 12-pair plateau measurement with a build combining both open PRs (#757 call-trace residency, #759 measured allocator overhead), verified by checksum at the maven-local and shaded-jar stages. Result: anon paired delta 59.29 +- 8.95 MiB, NMT paired delta 30.24 +- 0.11, profiler counters 23.83 logical + 0.97 measured overhead. Explained 55.04, residual +4.25 MiB at 0.48 sigma. No correction factors anywhere in the arithmetic. Measured allocator overhead per category shows why one multiplier could never have worked -- the categories differ by four orders of magnitude in allocation size: method_map 0.220 MiB logical 17.36 % <- the only category x1.17 fitted native_symbols 11.298 MiB 8.08 % calltrace 3.235 MiB 0.19 % dictionary 6.803 MiB 0.13 % <- 512 KB chunks The blanket x1.17 would charge 3.499 MiB where 0.967 MiB is measured, an over-credit of 2.5 MiB against a residual of a few MiB. Removing it WIDENS the gap as predicted: holding the anon delta at the previous run's 62.62 MiB for comparability, the residual moves +5.46 -> +7.58, a +2.1 MiB shift matching the over-credit. The figure reported is lower only because this run's anon delta was 3.3 MiB smaller -- between-run variance, not accounting. Process-wide arena waste is now measured too: 188.31 MiB free-but-held, of which only 0.13 MiB is trimmable, so malloc_trim could reclaim almost none of it. That is genuine fragmentation, matching the interleaving mechanism. It is process-wide and dominated by the JVM, so it is reported to make the allocator's cost visible rather than to attribute it -- but the scale is telling: the residual is 2.3 % of it. Flagged as the next step: these counters CANNOT attribute arena waste, because they are emitted through the profiler's own JFR and the tracing-only arm produces none, so no paired delta exists. memsweep/malloc_info_probe.c (LD_PRELOAD, works with or without the profiler) can supply both arms. Also refreshes the conditions table (duration=300, plateau sampling), the build checksum, and retires the stale next-step and named-bias entries that described the factor as pending. Remaining coverage gap recorded honestly: jfr_buffers, liveness, line_tables, thread_local, thread_filter and wallclock still use plain record() and report zero overhead, so the measured 0.967 MiB is a lower bound by ~0.2-0.4 MiB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Two new families of counters that replace estimated numbers with measured ones in memory-overhead accounting:
native_mem_chunk_overhead_bytes.<category>— the rounding-plus-header cost the allocator adds on top of the logical bytes theNM_*gauges record, measured per allocation viamalloc_usable_size().malloc_{arena,in_use,free_held,trimmable,mmap}_bytes— process-wide glibc arena state frommallinfo2(), sampled on the JFR flush path.Why
Reconciling the profiler's counters against process RSS previously required two correction factors borrowed from a different workload: a
×1.17chunk-overhead multiplier and an unmeasured allowance for free-but-held arena pages. Both are properties of the allocator, not of the profiler — under tcmalloc or jemalloc they behave differently — so they belong in reported counters rather than folded into a "profiler cost" figure.Overhead is a function of per-allocation size, so no single multiplier can serve categories with different size mixes. Measured:
MethodMapnode)×1.17is right hereSBTable)On a real profiled JVM the difference is material:
calltracenative_symbolsdictionarythread_infoAcross the malloc-backed categories the blanket factor charges ~3.0 MiB where 1.0 MiB is measured.
native_symbols— the largest category — pays 8.24 %, about half what was assumed.Design notes
Overhead is a separate gauge, not folded into
native_mem_live_bytes. Logical bytes stay directly comparable tosizeof()arithmetic; the extra that RSS pays is visible alongside.The per-chunk header is probed at first use, not hardcoded. The allocator in force is not knowable at compile time: an LD_PRELOAD'd tcmalloc or jemalloc adds no per-object header while
__GLIBC__stays defined, so assuming glibc's 8 bytes would invent overhead per allocation. The probe allocates same-size blocks and takes the smallest positive address stride (stride = usable + header); implausible results fall back to 0, reporting only observable rounding rather than guessing. It finds 8 on glibc and independently reproduces a known case (96 B request → 112 B footprint).Arena counters are
Counters, notNM_*categories.nativeMem.hdocuments that the categories partition the profiler's own allocations (sum(category) == total); arena slack is mostly other subsystems' chunks stranded by interleaving and is not attributable to any profiler allocation, so adding it as a category would break that invariant. Reported separately, the two bracket the profiler's contribution: lower bound = per-category counters, upper bound = those plus process-wide free-but-held.keepcostis reported apart fromfordblksbecause it separates glibc's trim-threshold retention (reclaimable) from genuine fragmentation (not).NM_NATIVE_SYMBOLSneeded a different mechanism. It is a gauge —setLive(memoryUsage())over a sum of logical sizes — so no pointer exists at record time.CodeCache::memoryUsage()already walks every symbol name, so it accumulates overhead in that same pass via an out-param. The measurement lives inNativeFunc::nameOverhead()because onlyNativeFuncknows the real allocation base:namepoints into the block at offsetsizeof(NativeFunc), so querying the allocator withnameitself would be undefined.Cost:
mallinfo2()walks every arena taking each arena lock — flush path only, never reachable from the sampling signal handler.malloc_usable_size()is a size-word read on the alloc/free paths that already call into the allocator.Known limits
jfr_buffers,livenessandline_tablesstill record via plainrecord()and contribute zero.calltrace's zero is correct rather than missing — it is mmap-backed and pays no malloc chunk overhead._blobsarray is excluded fromnative_symbolsoverhead: it comes fromnew CodeBlob[], whose returned pointer is not guaranteed to be the allocator's block base. One allocation per library, so excluding it understates negligibly rather than risking an unsound reading.mallinfo()silently truncates past 2 GiB, so it is not a usable fallback.Testing
mallocFootprint_ut.cppandmallocArenaStats_ut.cppvalidate the premises, not just the plumbing — and two of them corrected assumptions made while writing them:align16(S + 8)— and settles that it must be measured.malloc_trimreclaims 0.03 MiB.Sanitizer builds substitute an allocator that reports no usable-size slack and defeats the header probe, so overhead reads 0 there. That is the honest answer for such an allocator, so magnitude assertions skip rather than fail.
Release, debug, ASan and TSan all pass, including
codeCache_utandlibraries_ut.🤖 Generated with Claude Code