Skip to content

fix(nativemem): count NM_CALLTRACE residency, not reserved chunk capacity - #757

Merged
jbachorik merged 3 commits into
mainfrom
pr-nm-calltrace-residency
Aug 28, 2026
Merged

fix(nativemem): count NM_CALLTRACE residency, not reserved chunk capacity#757
jbachorik merged 3 commits into
mainfrom
pr-nm-calltrace-residency

Conversation

@rkennke

@rkennke rkennke commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

NM_CALLTRACE reported the virtual capacity of the call-trace arena rather than the memory actually touched, unlike every other NM_* category. This makes it report residency instead.

LinearAllocator::allocateChunk() recorded a whole 8 MiB CALL_TRACE_CHUNK the moment it was mmap'd — before any byte in it was touched. Chunks are then filled incrementally by alloc()'s bump pointer, and reserveChunk() eagerly reserves the next chunk once the current crosses 50% fill, so at any instant at least one fully-counted chunk was mostly or entirely untouched.

The fix records in alloc(), for exactly the bytes handed out. Because the bump pointer advances monotonically and every returned pointer is written into immediately by its caller, cumulative bump-allocated bytes track touched — and therefore resident — memory directly, with no correction factor.

Why it matters

This counter was feeding a memory-overhead reconciliation that had been closed with a ×0.829 residency correction factor borrowed from a different workload. That factor was inflating the call-trace term by ~16 MiB and masking a real, unexplained gap. With the counter measuring directly, the reconciliation is honestly 20–33% under-explained rather than apparently closed — see doc/performance/dd-trace-doe-reconciliation-2026-08-27.md on memory-usage-sweep-handoff.

It also reorders the optimisation targets: call-trace was believed to be the dominant category and is actually third, behind native_symbols and dictionary.

Measurements

dd-trace-doe enterprise workload, 12 reps: calltrace 24.23 → 3.53 MiB, a 6.9× over-report. Peak touched bytes never exceed 6.42 MiB, so this is not a sampling-phase artifact.

Isolated synthetic sweep, same build, only the accounting differing:

Accounting NM_CALLTRACE All categories
reserved capacity (before) 24.53 MiB 42.23 MiB
touched bytes (after) 5.38 MiB 23.07 MiB

Every other category was byte-identical across that pair, confirming the change is isolated to this counter.

Cost

Isolated allocator microbenchmark, 3 runs each, sd < 1 ns:

Scenario Before After After, peak established
single thread 12.62 ns 25.66 ns 18.15 ns
8 threads, contended 64.87 ns 116.3 ns ~78.3 ns

Relative cost roughly doubles, but alloc() is reached only from the key_value == 0 branch of CallTraceHashTable::put() — only for a call trace not already in the table. A repeat sample of a known stack takes findCallTrace() and never allocates. So this is a per-distinct-trace cost, not per-sample, which puts it in the low milliseconds per 90-second run.

About 7.4 ns of the single-thread increase is record()'s peak high-water CAS. Its comment notes it fires rarely "since _max is monotonic" — true for the old once-per-chunk call site, but at a per-alloc call site during pure growth every allocation sets a new high-water. Normal rotation leaves live below an established peak, giving the third column. Coarsening that CAS is a follow-up worth measuring; deliberately not bundled here.

Also in this change

  • Latent null dereference fixed. allocateChunk() returns NULL on mmap failure and detachChunks() stores that into _tail, so the destructor's freeChunk(_tail) could be reached with nothing to free. Previously harmless; this change dereferences the chunk to read its extent, so it now guards.
  • Stale header comment corrected. nativeMem.h described NM_CALLTRACE as an "arena reserved" figure with CALLTRACE_STORAGE_BYTES as its used slice. Both now count touched bytes, and their small difference is the call-trace hash tables — so computing arena waste as the difference would yield ~0.

Known limitation

Bump-allocated bytes are a lower bound on residency. clear() resets offs and un-records, but does not munmap the retained _tail, so pages it already touched stay resident while the counter forgets them. The dominant rotation path (detachChunksfreeChunks) does genuinely munmap, bounding this at roughly one chunk. Tracking a per-chunk touched high-water — kept across clear(), dropped only on real munmap — would close it. Documented in the code and in memory-sweep-results-linux.md.

Testing

linearAllocator_nativemem_ut.cpp (new) pins the behaviour: byte-accuracy on a fresh chunk, across a chunk boundary with one pre-reserved, and clean return to zero via both clear() and freeChunks(). Verified non-vacuous — against the old code it fails, reporting a whole 1 MiB chunk where 12,800 B had been handed out.

Release, debug, ASan and TSan all green for linearAllocator_nativemem_ut, nativeMem_ut, test_callTraceStorage, stress_callTraceStorage. The sanitizer runs matter here because the change adds a read of current->offs on the free path; TSan confirms no race and ASan no use-after-free.

🤖 Generated with Claude Code

…city

LinearAllocator recorded a whole CALL_TRACE_CHUNK (8 MiB) into NM_CALLTRACE in
allocateChunk(), at the moment the chunk was mmap'd -- before a single byte in
it had been touched. Chunks are then filled incrementally by alloc()'s bump
pointer, and reserveChunk() eagerly reserves the next chunk once the current one
crosses 50% fill, so at any instant at least one fully-counted chunk was mostly
or entirely untouched. The counter moved in 8 MiB steps regardless of real use,
reporting virtual capacity where every other NM_* category reports touched bytes.

Record in alloc() instead, for exactly the bytes handed out. Because the bump
pointer advances monotonically and every returned pointer is written into
immediately by its caller, cumulative bump-allocated bytes track touched --
and therefore resident -- memory directly, with no correction factor.
freeChunk()/freeChunks() decrement each chunk's consumed extent, captured
before safeFree() unmaps it, and clear() un-records the retained _tail's extent
explicitly since no freeChunk() runs for it.

Measured on the dd-trace-doe enterprise workload (12 reps): calltrace falls from
24.23 to 3.53 MiB, a 6.9x over-report. Peak touched bytes never exceed 6.42 MiB,
so this is not a sampling-phase artifact. In an isolated synthetic sweep the
same change moves the counter 24.53 -> 5.38 MiB with every other category
byte-identical, confirming the change is isolated to this one counter.

Cost, isolated allocator microbenchmark (3 runs each, sd < 1 ns):

                        before    after    after, peak established
  single thread        12.62 ns  25.66 ns          18.15 ns
  8 threads contended  64.87 ns  116.3 ns         ~78.3 ns

Relative cost roughly doubles, but alloc() is reached only from the
key_value == 0 branch of CallTraceHashTable::put() -- i.e. only for a call trace
not already in the table; a repeat sample of a known stack takes findCallTrace()
and never allocates. So this is a per-distinct-trace cost, not a per-sample one,
putting it in the low milliseconds per 90-second run. About 7.4 ns of the
single-thread increase is record()'s peak high-water CAS, which fires on every
allocation only during pure growth; rotation leaves live below an established
peak, giving the third column.

Also fixes a latent null dereference: allocateChunk() returns NULL on mmap
failure and detachChunks() stores that into _tail, so the destructor's
freeChunk(_tail) could be reached with nothing to free -- previously harmless,
but this change dereferences the chunk to read its extent.

The nativeMem.h header comment described NM_CALLTRACE as an "arena reserved"
figure with CALLTRACE_STORAGE_BYTES as its used slice. Both now count touched
bytes and their small difference is the call-trace hash tables, so computing
arena waste as the difference would yield ~0. Comment corrected.

Known limitation, documented in the code and in
doc/performance/memory-sweep-results-linux.md: bump-allocated bytes are a lower
bound on residency. clear() resets offs and un-records, but does not munmap the
retained _tail, so pages it already touched stay resident while the counter
forgets them. The dominant rotation path (detachChunks -> freeChunks) does
genuinely munmap, bounding this at roughly one chunk. Tracking a per-chunk
touched high-water, kept across clear() and dropped only on real munmap, would
close it.

Tests: linearAllocator_nativemem_ut.cpp pins the new behaviour and fails against
the old code, reporting a whole 1 MiB chunk where 12,800 B were handed out.
Release, debug, ASan and TSan builds all green for
linearAllocator_nativemem_ut, nativeMem_ut, test_callTraceStorage and
stress_callTraceStorage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkennke
rkennke requested a review from a team as a code owner August 27, 2026 12:27
@dd-octo-sts

dd-octo-sts Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Scan-Build Report

User:runner@runnervmgx7h7
Working Directory:/home/runner/work/java-profiler/java-profiler/ddprof-lib/src/test/make
Command Line:make -j4 all
Clang Version:Ubuntu clang version 18.1.3 (1ubuntu1)
Date:Fri Aug 28 13:28:25 2026

Bug Summary

Bug TypeQuantityDisplay?
All Bugs1
Logic error
Dereference of null pointer1

Reports

Bug Group Bug Type ▾ File Function/Method Line Path Length
Logic errorDereference of null pointerfaultInjection.cppcrashNow242

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3160af5ba8

ℹ️ 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".

Comment thread ddprof-lib/src/main/cpp/linearAllocator.cpp
@dd-octo-sts

dd-octo-sts Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #33175408346 | Commit: 1d1eb0b | Duration: 14m 35s (longest job)

All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - - -
8-ibm - - -
8-j9 - -
8-librca - -
8-orcl - - -
11 - - -
11-j9 - -
11-librca - -
17 - -
17-graal - -
17-j9 - -
17-librca - -
21 - -
21-graal - -
21-librca - -
25 - -
25-graal - -
25-librca - -

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Summary: Total: 32 | Passed: 32 | Failed: 0


Updated: 2026-08-28 13:44:46 UTC

@dd-octo-sts

dd-octo-sts Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

39 passed, 1 failed out of 40 configurations

Test Matrix

Platform JDK 8 JDK 11 JDK 17 JDK 21 JDK 25
glibc-x64-hotspot
glibc-x64-openj9
glibc-arm64-hotspot
glibc-arm64-openj9
musl-x64-hotspot
musl-x64-openj9
musl-arm64-hotspot
musl-arm64-openj9

Failure Details

musl-x64-hotspot-jdk25

Tracer+profiler:

"  This may indicate tracer is not capturing any requests"
""
"[7/8] Checking for unexpected events..."
"  ✓ No unexpected events found"
""
"[8/8] Scenario-specific validation (ddprof_with_tracer)..."
"  Validating tracer+profiler scenario..."
"  ✓ Tracer+profiler scenario checks passed"
""
"=== Validation Summary ==="
"ExecutionSample:            0.0 events (datadog.ExecutionSample)"
"Stack traces:               0.0 samples"
"Thread diversity:           0.0 threads"
"Allocation samples:         0 events (jdk.ObjectAllocationSample)"
"ThreadAllocationStatistics: 48 events"
""
"VALIDATION_FAILED: One or more checks did not pass"

Script executed successfully: 425 commands
VALIDATION_FAILED: Validation checks did not pass

Links

@jbachorik

Copy link
Copy Markdown
Collaborator

LGTM!

@jbachorik jbachorik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! One small nit only.

Comment thread ddprof-lib/src/main/cpp/linearAllocator.cpp
Answering review feedback asking whether the _tail dereference needs a guard.

A guard at that one line would not have helped: the very next statement
(pre-existing) also dereferences _tail, and clear() already dereferences
_reserve unguarded at the top of the function. So the hole is the function, not
the line.

It is reachable: detachChunks() sets both _tail and _reserve to NULL when it
cannot allocate a replacement chunk, leaving the allocator deliberately unusable
rather than risking a double free. A clear() after that would have faulted on
_reserve->prev before ever reaching the _tail read.

Early-returns on either pointer being NULL, which covers both dereferences.
Complements the same guard already added to freeChunk() for the destructor path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@datadog-prod-us1-6

This comment has been minimized.

@jbachorik
jbachorik merged commit 0a0e6ba into main Aug 28, 2026
112 of 115 checks passed
@jbachorik
jbachorik deleted the pr-nm-calltrace-residency branch August 28, 2026 13:50
@github-actions github-actions Bot added this to the 1.50.0 milestone Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants