Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
30e9a13
Carve slow/e2e tests into separate testSlow tasks, run only nightly/p…
jbachorik Jul 31, 2026
5502908
Fix TagFilter import in ProfilerTestRunner (wrong package)
jbachorik Jul 31, 2026
3476878
Implement reference chains for surviving live-heap samples (PROF-15341)
jbachorik Jul 17, 2026
cda7958
Add C++ unit tests for reference-chain tracking
jbachorik Jul 17, 2026
e95cb51
Add Java integration tests for reference-chain tracking
jbachorik Jul 17, 2026
b7dd165
CI and chaos-harness touch-ups for reference-chains testing
jbachorik Jul 17, 2026
8721b44
Add reference-chains architecture and planning docs
jbachorik Jul 17, 2026
cc6cbcd
Add manual reproduction harness for reference-chains
jbachorik Jul 21, 2026
cee9ae0
Label reference chains with their GC root kind
jbachorik Jul 21, 2026
8bbad21
Checkpoint: frontier-table RSS counters, repro compare script, VMStru…
jbachorik Jul 22, 2026
19ab067
Checkpoint: fold review findings and source-verified GC fixes into VM…
jbachorik Jul 22, 2026
8a8da30
Checkpoint: fold round-2 adversarial review into VMStructs design doc
jbachorik Jul 22, 2026
cb202d0
Checkpoint: propose fix for root-attribution staleness
jbachorik Jul 22, 2026
c727d56
Checkpoint: close the residual gap with bounded rotating re-expansion
jbachorik Jul 22, 2026
6e01bdf
Add JVMTI incremental heap-walk extension proposal sketch
jbachorik Jul 22, 2026
5004dfc
Fix InstanceKlass tail-layout, array-header-size, and resumable-walk …
jbachorik Jul 23, 2026
3962f40
Fix compressed-oops detection on JDK 24+ (JDK-8329306 renamed exporte…
jbachorik Jul 23, 2026
2358cb7
Fix compressed-oops-detection SIGABRT on JDK 8-16 (narrow-oop fields …
jbachorik Jul 23, 2026
3f397e8
Allow manual VMStructs heap walk on all collectors except ZGC
jbachorik Jul 24, 2026
04649d2
Require explicit opt-in for the JVMTI FollowReferences fallback
jbachorik Jul 24, 2026
ab1c7a6
Add JDK/GC selection to reference-chains repro scripts
jbachorik Jul 24, 2026
427eb0b
Fix CodeQL autobuild link failure by including vmStructs.inline.h
jbachorik Jul 24, 2026
07e0e42
Make the bounded manual walk the default reference-chain search path
jbachorik Jul 27, 2026
545b858
Remove the dead raw-oop manual-expand code path
jbachorik Jul 27, 2026
a37a96f
Remove the now-unused FieldWalker raw-oop heap walker
jbachorik Jul 27, 2026
9cc4a77
Remove unused VMStructs raw-oop-walk scaffolding
jbachorik Jul 27, 2026
3907217
Add reference-chain sweep and JFR report tooling
jbachorik Jul 27, 2026
e085e68
Assert reference chains persist across JFR dumps in E2E test
jbachorik Jul 27, 2026
214b390
fix: harden expandFrontier() error handling and JFR/report tooling ed…
jbachorik Jul 28, 2026
79084fe
Remove allowjvmtifallback flag and whole-graph FollowReferences fallback
jbachorik Jul 28, 2026
e0e68e7
Mock VM::_libjvm in ReferenceChainJfrRoundtripTest fixture
jbachorik Jul 28, 2026
c587fe7
Address review feedback: static-field roots, class-map generation, JN…
jbachorik Jul 29, 2026
c0e3c1b
Fix stale cached JNI class ref on BFS thread restart; add pending-exp…
jbachorik Jul 29, 2026
acc4105
Fix rotation truncation/cap-hit overwrite bug and stale-child scan-te…
jbachorik Jul 30, 2026
129d45b
Fix rotation budget starvation and stale-expanded dedup; re-clamp bor…
jbachorik Jul 30, 2026
2874885
Tag reference-chain e2e tests as slow
jbachorik Jul 31, 2026
1808111
Gate the first reference-chain search on a leak candidate, not just r…
jbachorik Aug 3, 2026
7a81d4a
Add sustained-trend hysteresis + heap-floor corroboration to leak det…
jbachorik Aug 4, 2026
52e9a2e
Fix UB in ELF vaddr-diff pointer arithmetic; correct stale architectu…
jbachorik Aug 4, 2026
feb3a72
Merge remote-tracking branch 'origin/main' into jb/reference-chains
jbachorik Aug 4, 2026
ed6525a
Merge branch 'main' into jb/reference-chains
jbachorik Aug 4, 2026
8060cc0
Fix remaining null-base pointer-arithmetic UB in symbols_linux.cpp
jbachorik Aug 4, 2026
7fddcbc
Merge remote-tracking branch 'origin/jb/reference-chains' into jb/ref…
jbachorik Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,15 @@ doc/temp/

# CLAUDE.md is auto-generated from AGENTS.md bootstrap instructions
CLAUDE.md

# OS/editor cruft
.DS_Store

# AI review/agent tooling scratch state
.sphinx/
.skill-builder-temp/
.claude/scheduled_tasks.lock

# Python bytecode cache
__pycache__/
*.pyc
106 changes: 106 additions & 0 deletions ddprof-lib/src/main/cpp/arguments.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include "arguments.h"
#include "vmEntry.h"

#include <algorithm>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
Expand Down Expand Up @@ -81,6 +82,26 @@ static const Multiplier UNIVERSAL[] = {
// and keep the liveness track of 10% of the allocation
// samples
// generations - track surviving generations
// referencechains[=BOOL[:hops=N][:budget=N][:ttl=N][:framecap=N][:pausetarget=N][:painbudget=N][:firstpassbudget=N]]
// - (PROF-15341, off by default) tag/BFS-walk live-heap
// samples' referrer chains back toward a GC root.
// pausetarget=N (ms) is the pause-time-SLO ceiling
// ReferenceChainTracker::updatePacing() adapts the
// effective budget/cadence toward (pause-time pacing
// controller). painbudget=N (percent) bounds how much
// wall-clock time a *restarted* search (one begun
// after a prior search already completed/abandoned)
// may spend on average - see PainBudget (painBudget.h).
// firstpassbudget=N overrides just the search's
// one-shot, root-seeded first pass's edge budget
// (default 0 - auto-scales from budget=N instead,
// see ReferenceChainTracker::AUTO_FIRST_PASS_BUDGET_*
// in referenceChains.h) since that pass alone
// decides which GC roots ever enter the frontier at
// all, unlike every later pass's cheap, incremental
// per-node expansion.
// Sub-options are placeholders pending future tuning;
// see doc/architecture/LiveHeapReferenceChains*.md
// lightweight[=BOOL] - enable lightweight profiling - events without
// stacktraces (default: true)
// remotesym[=BOOL] - enable remote symbolication for native frames
Expand Down Expand Up @@ -428,6 +449,91 @@ Error Arguments::parse(const char *args) {
_nativesocket = true;
}

CASE("referencechains")
{
// Sub-options are colon-delimited key=value pairs after the boolean,
// e.g. "referencechains=true:hops=64:budget=2000". Parsed manually
// (not via strtok) because the outer arg loop above is itself mid
// strtok(..., ",") over the same buffer - a nested strtok call would
// clobber its saved state.
char *config = value ? strchr(value, ':') : nullptr;
if (config) {
*(config++) = 0;
}
if (value != NULL) {
switch (value[0]) {
case 'n': // no
case 'f': // false
case '0': // 0
_reference_chains = false;
break;
default:
_reference_chains = true;
}
} else {
_reference_chains = true;
}
char *cursor = config;
while (cursor != NULL) {
char *next = strchr(cursor, ':');
if (next) {
*(next++) = 0;
}
char *eq = strchr(cursor, '=');
if (eq) {
*(eq++) = 0;
// Floor every sub-option at the parse boundary rather than
// trusting a downstream cast/clamp to make an operator-supplied
// negative value safe: a negative hops value in particular gets
// compared as `depth >= (u32)ctx->hop_cap` (referenceChains.cpp),
// so an unclamped negative wraps to ~4e9 and silently disables
// the hop cap entirely - the opposite of the flag's intent, and
// it removes the one guard that otherwise bounds how long a
// single reference chain (and therefore its
// datadog.ReferenceChain JFR event) can grow. A negative budget
// similarly collapses ReferenceChainTracker::_effective_budget
// to 0 (updatePacing()'s own PID-clamp logic), which truncates
// every pass immediately and leaves the search RUNNING
// (re-walking the whole graph each cadence) until TTL instead of
// making progress. A negative framecap is handed straight to
// FrontierTable's constructor, which floors it to a
// zero-capacity table (that class's own std::max(max_cap, 0)),
// silently disabling tracking rather than erroring. ttl/
// pausetarget/painbudget already have incidental downstream
// clamps (runPass()'s `_ttl_ms > 0` gate, this class's own
// PidController/PainBudget std::max(..., 0) calls) but are
// floored here too so every sub-option's validation lives at one
// boundary instead of being split between here and several
// unrelated call sites. hops/budget/framecap are also ceiling-
// clamped (MAX_REFERENCE_CHAINS_HOP_CAP/_BUDGET/_FRONTIER_CAP,
// arguments.h) for the same reason painbudget/firstpassbudget
// are below: an unbounded operator-supplied value would otherwise
// flow straight into a loop bound or FrontierTable's allocation.
if (strcasecmp(cursor, "hops") == 0) {
_reference_chains_hop_cap =
std::min(std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_HOP_CAP);
} else if (strcasecmp(cursor, "budget") == 0) {
_reference_chains_budget =
std::min(std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_BUDGET);
} else if (strcasecmp(cursor, "ttl") == 0) {
_reference_chains_ttl_ms = std::max(atol(eq), 0L);
} else if (strcasecmp(cursor, "framecap") == 0) {
_reference_chains_frontier_cap = std::min(
std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_FRONTIER_CAP);
} else if (strcasecmp(cursor, "pausetarget") == 0) {
_reference_chains_pause_target_ms = std::max(atol(eq), 0L);
} else if (strcasecmp(cursor, "painbudget") == 0) {
_reference_chains_pain_budget_percent =
std::min(std::max(atoi(eq), 0), 100);
} else if (strcasecmp(cursor, "firstpassbudget") == 0) {
_reference_chains_first_pass_budget = std::min(
std::max(atoi(eq), 0), MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET);
}
}
cursor = next;
}
}

DEFAULT()
if (_unknown_arg == NULL)
_unknown_arg = arg;
Expand Down
122 changes: 122 additions & 0 deletions ddprof-lib/src/main/cpp/arguments.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,105 @@ const long DEFAULT_ALLOC_INTERVAL = 524287; // 512 KiB
const int DEFAULT_WALL_THREADS_PER_TICK = 16;
const int DEFAULT_JSTACKDEPTH = 2048;

// Every constant below is a provisional default pending empirical
// tuning (see doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md)
// - none of these values are backed by a benchmark run against this
// codebase. Each is chosen conservatively from cited precedent or from the
// shape of an existing, already-tuned subsystem, per the rationale below;
// a future JMH/async-profiler benchmark matrix (see
// doc/architecture/LiveHeapReferenceChains-BenchmarkPlan.md) is the intended
// path to replacing them with measured values.
//
// Hop cap: mirrors HotSpot's own JFR leak-profiler chain cap (~200 hops,
// split 100/100 from leaf and from root), cited in
// doc/architecture/LiveHeapReferenceChains.md's "Approach B" section - the
// closest real-world precedent for "how many hops does a referrer-type
// chain typically need" that this codebase can cite without measuring it
// itself.
const int DEFAULT_REFERENCE_CHAINS_HOP_CAP = 200;
// Per-pass edge budget: no cited precedent gives a number for this (JFR's
// leak profiler does not bound itself by a per-pass edge count - it runs to
// completion inside one already-scheduled GC pause). Chosen as a round,
// conservative middle value intended to keep a single FollowReferences-
// triggered safepoint short without so small a budget that a search needs
// an impractical number of passes to make progress. A future benchmark pass
// should measure per-pass wall-clock pause distribution at this value and adjust.
const int DEFAULT_REFERENCE_CHAINS_BUDGET = 1000; // edges expanded per BFS pass
// Per-search TTL: a conservative round number (one minute) chosen so a
// slow-moving or stalled search is bounded to a human-noticeable but not
// excessive lifetime, in the absence of any measured "passes needed to
// reach a target sample at various depths" data (a future benchmark's stated goal).
const long DEFAULT_REFERENCE_CHAINS_TTL_MS = 60000; // per-search wall-clock TTL
// Frontier-size cap: sized relative to LivenessTracker's own tuned ceiling
// (MAX_TRACKING_TABLE_SIZE = 262144, livenessTracker.h) rather than derived
// from any BFS-specific measurement - the design doc explicitly flags that
// LivenessTracker's allocation-sample-rate sizing formula does not transfer
// to a graph-search frontier (Open Question 2), so this only borrows the
// same order of magnitude, quartered as a conservative starting point since
// a FrontierEntry is smaller than a TrackingEntry but per-hop fan-out could
// still be large. Not a scaled/derived value - just a conservative guess
// pending a future frontier-table peak-occupancy measurement.
const int DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP = 65536; // max live frontier entries per search
// Pause-time-SLO ceiling (pause-time pacing controller, doc/architecture/
// LiveHeapReferenceChains-RemainingWorkPlan.md): target ceiling, per pass, on
// wall-clock time spent inside the safepoint-triggering
// FollowReferences/GetObjectsWithTags call
// (ReferenceChainTracker::updatePacing(), referenceChains.cpp). Like every
// other constant in this block this is a round, provisional default with no
// benchmark behind it - picking the real number is explicitly a future
// measurement question (design doc's Open Question 2), not a value to guess
// here; this only exists so the feedback loop this ceiling drives has
// something to target end-to-end before that measurement happens.
const long DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS = 5; // ms per pass
// Pain budget refill rate (ReferenceChainTracker::PainBudget, painBudget.h):
// the fraction of wall-clock time a *restarted* search is allowed to spend
// inside FollowReferences/GetObjectsWithTags safepoints, on average, before a
// later restart must wait for the debt from the previous search's cost to
// drain. Expressed as an integer percent (1 = 1%) for readability - see
// PainBudget's own header comment for why this single ratio needs no
// benchmark-derived tuning the way the per-pass constants above do, only a
// choice of how much background cost is acceptable. Round, provisional
// default like every other constant in this block.
const int DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT = 1;
// First-pass edge budget override: the search's one-and-only root-seeded
// FollowReferences(0, nullptr, nullptr, ...) call (ReferenceChainTracker::runPass()'s
// !_search_started branch) enumerates every GC root in one JVMTI-controlled
// traversal order and stops admitting once this budget is spent - any root
// FollowReferences had not yet reached is excluded from the frontier for the
// rest of that search (every later pass only expands forward from already-
// admitted frontier entries, see expandFrontier()'s own comment). Unlike
// DEFAULT_REFERENCE_CHAINS_BUDGET, which bounds every pass including the many
// cheap, per-node expansion passes that follow, this only ever spends once
// per search, so a much larger one-time ceiling is affordable. 0 (the
// default) means "no override - use the same budget as every other pass",
// preserving prior behavior for anyone not setting this explicitly.
const int DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET = 0;
// Upper clamp for an explicit firstpassbudget override: like painbudget just
// above, firstpassbudget was previously only floored at 0 with no ceiling.
// Unlike painbudget (a percentage, naturally bounded at 100), this is a raw
// edge count, so the ceiling is expressed relative to
// DEFAULT_REFERENCE_CHAINS_BUDGET (the per-pass budget every later pass is
// bounded by) rather than as its own standalone guess: a generous but finite
// multiple still lets the one-time root pass be far larger than a normal
// pass (its intended purpose) while keeping an operator from disabling the
// safepoint-pause-bounding mechanism entirely for that first FollowReferences
// call.
const int MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET =
DEFAULT_REFERENCE_CHAINS_BUDGET * 1000;
// Upper clamps for hops/budget/framecap: like MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET
// just above, these were previously only floored at 1 with no ceiling, so an
// operator typo (an extra digit) or a mistaken value flows straight into a
// loop bound (hops), a per-pass edge count (budget), or FrontierTable's
// capacity (framecap) unchecked. Same generous-but-finite-multiple-of-the-
// default approach as the first-pass budget clamp: large enough that no
// legitimate configuration should ever hit the ceiling, small enough to
// still fail a badly mistyped value safely instead of feeding it straight
// into an allocation or loop bound.
const int MAX_REFERENCE_CHAINS_HOP_CAP = DEFAULT_REFERENCE_CHAINS_HOP_CAP * 1000;
const int MAX_REFERENCE_CHAINS_BUDGET = DEFAULT_REFERENCE_CHAINS_BUDGET * 1000;
const int MAX_REFERENCE_CHAINS_FRONTIER_CAP =
DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP * 1000;

const char *const EVENT_NOOP = "noop";
const char *const EVENT_CPU = "cpu";
const char *const EVENT_ALLOC = "alloc";
Expand Down Expand Up @@ -177,6 +276,21 @@ class Arguments {
double _live_samples_ratio;
bool _record_heap_usage;
bool _gc_generations;
// Reference-chain tracking (PROF-15341 - see
// doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md and
// -RemainingWorkPlan.md). Read by ReferenceChainTracker::start()
// (referenceChains.cpp) to size the frontier table and seed the per-search
// hop/budget/TTL tunables and the pause-time-SLO ceiling that
// updatePacing() adapts the effective budget/cadence toward.
bool _reference_chains;
int _reference_chains_hop_cap;
int _reference_chains_budget;
long _reference_chains_ttl_ms;
int _reference_chains_frontier_cap;
long _reference_chains_pause_target_ms;
int _reference_chains_pain_budget_percent;
int _reference_chains_first_pass_budget;
// Explicit opt-in for the legacy whole-graph JVMTI FollowReferences walk.
long _nativemem;
int _jstackdepth;
int _safe_mode;
Expand Down Expand Up @@ -218,6 +332,14 @@ class Arguments {
_live_samples_ratio(0.1), // default to liveness-tracking 10% of the allocation samples
_record_heap_usage(false),
_gc_generations(false),
_reference_chains(false),
_reference_chains_hop_cap(DEFAULT_REFERENCE_CHAINS_HOP_CAP),
_reference_chains_budget(DEFAULT_REFERENCE_CHAINS_BUDGET),
_reference_chains_ttl_ms(DEFAULT_REFERENCE_CHAINS_TTL_MS),
_reference_chains_frontier_cap(DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP),
_reference_chains_pause_target_ms(DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS),
_reference_chains_pain_budget_percent(DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT),
_reference_chains_first_pass_budget(DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET),
_nativemem(-1),
_jstackdepth(DEFAULT_JSTACKDEPTH),
_safe_mode(0),
Expand Down
2 changes: 1 addition & 1 deletion ddprof-lib/src/main/cpp/callTraceHashTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class CallTraceHashTable {
// - ACQUIRE loads in collect(), put(), and putWithExistingId()
// Required for correct visibility on weakly-ordered architectures (aarch64).
LongHashTable* _table;

volatile u64 _overflow;

u64 calcHash(int num_frames, ASGCT_CallFrame *frames, bool truncated);
Expand Down
1 change: 1 addition & 0 deletions ddprof-lib/src/main/cpp/callTraceStorage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

#include <unistd.h>
#include "callTraceStorage.h"
#include "counters.h"
#include "log.h"
Expand Down
24 changes: 24 additions & 0 deletions ddprof-lib/src/main/cpp/counters.h
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@
X(SAFECOPY_FAILED, "safecopy_failed") \
X(SAFEFETCH_FAILED, "safefetch_failed") \
X(STACKWALK_LONGJMP_RECOVERED, "stackwalk_longjmp_recovered") \
/* A resolved datadog.ReferenceChain could not be cached in \
* ReferenceChainTracker::_resolved_chains (referenceChains.h): a brand-new \
* leak-candidate klass arrived with the cache already at \
* MAX_RESOLVED_CHAINS, so its chain is dropped rather than evicting some \
* other still-live sample's chain. See that constant's own comment. */ \
X(REFERENCE_CHAIN_EVENTS_DROPPED, "reference_chain_events_dropped") \
/* ReferenceChainTracker::releaseSearchTags() (referenceChains.cpp) failed \
* to call GetObjectsWithTags() for at least one batch - the search's tag \
* release is retried on a later call rather than proceeding, but this \
* counts how often that retry path is taken. */ \
X(REFERENCE_CHAIN_TAG_RELEASE_FAILED, "reference_chain_tag_release_failed") \
/* Profiler::writeReferenceChain() (profiler.cpp) could not acquire a \
* sample-record lock within its bounded retry budget and dropped the \
* already-dequeued datadog.ReferenceChain event for this dump - not \
* permanently lost, since ReferenceChainTracker::_resolved_chains (see \
* REFERENCE_CHAIN_EVENTS_DROPPED above) keeps the resolved chain cached \
* and re-emits it on a later dump while the leak candidate is still \
* live. */ \
X(REFERENCE_CHAIN_WRITE_DROPPED, "reference_chain_write_dropped") \
/* FrontierTable's own calloc/realloc-backed storage (referenceChains.cpp) - \
* outside NMT's visibility since it bypasses os::malloc, so this is the only \
* way to attribute its native RSS contribution. */ \
X(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, "reference_chain_frontier_table_bytes") \
X(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, "reference_chain_frontier_table_capacity") \
DD_COUNTER_TABLE_FAULT_INJECTION(X) \
DD_COUNTER_TABLE_FI_DEBUG(X) \
DD_COUNTER_TABLE_DEBUG(X)
Expand Down
Loading
Loading