Skip to content

Commit a636398

Browse files
jbachorikclaude
andcommitted
Fix reference-chain class-tag reuse after StringDictionary reset
StringDictionary::clearAll() (on profiler restart) wipes dict ids but JVMTI class tags survive, so ReferenceChainTracker::_class_tags held ids into a namespace that no longer existed. Add a generation counter to StringDictionary and have resolveLoadedClasses() detect a mismatch and force re-resolution, reusing existing JVMTI tags instead of always minting new ones. Also adds a firstpassbudget override for the root-seeded first BFS pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 25fa0f2 commit a636398

8 files changed

Lines changed: 347 additions & 84 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ static const Multiplier UNIVERSAL[] = {
8282
// and keep the liveness track of 10% of the allocation
8383
// samples
8484
// generations - track surviving generations
85-
// referencechains[=BOOL[:hops=N][:budget=N][:ttl=N][:framecap=N][:pausetarget=N][:painbudget=N]]
85+
// referencechains[=BOOL[:hops=N][:budget=N][:ttl=N][:framecap=N][:pausetarget=N][:painbudget=N][:firstpassbudget=N]]
8686
// - (PROF-15341, off by default) tag/BFS-walk live-heap
8787
// samples' referrer chains back toward a GC root.
8888
// pausetarget=N (ms) is the pause-time-SLO ceiling
@@ -92,6 +92,12 @@ static const Multiplier UNIVERSAL[] = {
9292
// wall-clock time a *restarted* search (one begun
9393
// after a prior search already completed/abandoned)
9494
// may spend on average - see PainBudget (painBudget.h).
95+
// firstpassbudget=N overrides just the search's
96+
// one-shot, root-seeded first pass's edge budget
97+
// (default 0 - falls back to budget=N) since that
98+
// pass alone decides which GC roots ever enter the
99+
// frontier at all, unlike every later pass's cheap,
100+
// incremental per-node expansion.
95101
// Sub-options are placeholders pending future tuning;
96102
// see doc/architecture/LiveHeapReferenceChains*.md
97103
// lightweight[=BOOL] - enable lightweight profiling - events without
@@ -510,6 +516,8 @@ Error Arguments::parse(const char *args) {
510516
} else if (strcasecmp(cursor, "painbudget") == 0) {
511517
_reference_chains_pain_budget_percent =
512518
std::min(std::max(atoi(eq), 0), 100);
519+
} else if (strcasecmp(cursor, "firstpassbudget") == 0) {
520+
_reference_chains_first_pass_budget = std::max(atoi(eq), 0);
513521
}
514522
}
515523
cursor = next;

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ const long DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS = 5; // ms per pass
8989
// choice of how much background cost is acceptable. Round, provisional
9090
// default like every other constant in this block.
9191
const int DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT = 1;
92+
// First-pass edge budget override: the search's one-and-only root-seeded
93+
// FollowReferences(0, nullptr, nullptr, ...) call (ReferenceChainTracker::runPass()'s
94+
// !_search_started branch) enumerates every GC root in one JVMTI-controlled
95+
// traversal order and stops admitting once this budget is spent - any root
96+
// FollowReferences had not yet reached is excluded from the frontier for the
97+
// rest of that search (every later pass only expands forward from already-
98+
// admitted frontier entries, see expandFrontier()'s own comment). Unlike
99+
// DEFAULT_REFERENCE_CHAINS_BUDGET, which bounds every pass including the many
100+
// cheap, per-node expansion passes that follow, this only ever spends once
101+
// per search, so a much larger one-time ceiling is affordable. 0 (the
102+
// default) means "no override - use the same budget as every other pass",
103+
// preserving prior behavior for anyone not setting this explicitly.
104+
const int DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET = 0;
92105

93106
const char *const EVENT_NOOP = "noop";
94107
const char *const EVENT_CPU = "cpu";
@@ -251,6 +264,7 @@ class Arguments {
251264
int _reference_chains_frontier_cap;
252265
long _reference_chains_pause_target_ms;
253266
int _reference_chains_pain_budget_percent;
267+
int _reference_chains_first_pass_budget;
254268
long _nativemem;
255269
int _jstackdepth;
256270
int _safe_mode;
@@ -299,6 +313,7 @@ class Arguments {
299313
_reference_chains_frontier_cap(DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP),
300314
_reference_chains_pause_target_ms(DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS),
301315
_reference_chains_pain_budget_percent(DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT),
316+
_reference_chains_first_pass_budget(DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET),
302317
_nativemem(-1),
303318
_jstackdepth(DEFAULT_JSTACKDEPTH),
304319
_safe_mode(0),

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

Lines changed: 97 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,13 @@ Error ReferenceChainTracker::start(Arguments &args) {
243243

244244
_hop_cap = args._reference_chains_hop_cap;
245245
_budget = args._reference_chains_budget;
246+
// 0 (unset) falls back to _budget - see this field's own comment
247+
// (referenceChains.h) for why the first pass needs its own, larger
248+
// ceiling rather than sharing _budget/_effective_budget with every later
249+
// pass.
250+
_first_pass_budget = args._reference_chains_first_pass_budget > 0
251+
? args._reference_chains_first_pass_budget
252+
: _budget;
246253
_ttl_ms = args._reference_chains_ttl_ms;
247254

248255
// Pause-time pacing controller: (re)seed the controller's ceiling and the
@@ -603,7 +610,7 @@ void ReferenceChainTracker::restartSearch() {
603610
store(_search_state, (u8)SearchState::RUNNING);
604611
store(_abandon_reason, (u8)SearchAbandonReason::NONE);
605612
store(_search_start_ns, (u64)0);
606-
_expand_cursor = 1;
613+
_pending_expand.clear();
607614
_last_pass_gc_finish_epoch = 0;
608615
store(_last_pass_ns, (u64)0);
609616
store(_passes_run, 0);
@@ -648,7 +655,7 @@ void ReferenceChainTracker::resetSearchStateForTest(jvmtiEnv *jvmti,
648655
store(_search_state, (u8)SearchState::RUNNING);
649656
store(_abandon_reason, (u8)SearchAbandonReason::NONE);
650657
store(_search_start_ns, (u64)0);
651-
_expand_cursor = 1;
658+
_pending_expand.clear();
652659
_last_pass_gc_finish_epoch = 0;
653660
store(_last_pass_ns, (u64)0);
654661
store(_passes_run, 0);
@@ -760,6 +767,27 @@ jlong ReferenceChainTracker::tagAsRootForTest(jvmtiEnv *jvmti, JNIEnv *jni,
760767

761768
void ReferenceChainTracker::resolveLoadedClasses(jvmtiEnv *jvmti,
762769
JNIEnv *jni) {
770+
// Profiler::start() resets the class-name StringDictionary
771+
// (_class_map.clearAll(), profiler.cpp) whenever `reset || _start_time ==
772+
// 0` - which restarts its id namespace at 1, but does NOT touch any
773+
// class's JVMTI-level class-object tag (JVM-level state, unrelated to our
774+
// dictionary). Detect that reset via the dictionary's own generation
775+
// counter and drop every id this table cached from the now-gone
776+
// generation before the scan below - see _last_class_map_generation's own
777+
// comment (referenceChains.h) for why leaving them in place would keep
778+
// resolving heap references to the wrong (or nonexistent) class name.
779+
u64 current_generation = Profiler::instance()->classMap()->generation();
780+
bool class_map_reset = current_generation != _last_class_map_generation;
781+
if (class_map_reset) {
782+
_class_tags.clear();
783+
// Force the scan below to run even if GetLoadedClasses()'s count happens
784+
// to match the last-seen count - -1 can never equal `class_count`
785+
// (always >= 0), unlike 0 which is a legitimate "no classes loaded yet"
786+
// starting value.
787+
_last_resolved_class_count = -1;
788+
_last_class_map_generation = current_generation;
789+
}
790+
763791
jclass *classes = nullptr;
764792
jint class_count = 0;
765793
if (jvmti->GetLoadedClasses(&class_count, &classes) != JVMTI_ERROR_NONE ||
@@ -793,12 +821,18 @@ void ReferenceChainTracker::resolveLoadedClasses(jvmtiEnv *jvmti,
793821
for (jint i = 0; i < class_count; i++) {
794822
jclass klass = classes[i];
795823
jlong tag = 0;
796-
if (jvmti->GetTag(klass, &tag) == JVMTI_ERROR_NONE && tag == 0) {
797-
// Not yet tagged (by us or a prior pass) - resolve its name now, via
798-
// the same GetClassSignature + normalizeClassSignature +
799-
// Profiler::lookupClass sequence ObjectSampler::recordAllocation()
800-
// already uses (objectSampler.cpp:76-90), reused rather than
801-
// re-derived.
824+
// Resolve if not yet tagged (ordinary case: a newly-loaded class), or
825+
// unconditionally on a class-map reset (class_map_reset above) - a
826+
// class already tagged from a prior generation still carries that same
827+
// JVMTI tag (untouched by clearAll()), but the dictionary id it used to
828+
// map to is gone, so its name must be re-resolved into the new
829+
// generation too.
830+
if (jvmti->GetTag(klass, &tag) == JVMTI_ERROR_NONE &&
831+
(tag == 0 || class_map_reset)) {
832+
// Resolve its name now, via the same GetClassSignature +
833+
// normalizeClassSignature + Profiler::lookupClass sequence
834+
// ObjectSampler::recordAllocation() already uses
835+
// (objectSampler.cpp:76-90), reused rather than re-derived.
802836
char *class_name = nullptr;
803837
if (jvmti->GetClassSignature(klass, &class_name, nullptr) ==
804838
JVMTI_ERROR_NONE &&
@@ -809,8 +843,12 @@ void ReferenceChainTracker::resolveLoadedClasses(jvmtiEnv *jvmti,
809843
&name_len)) {
810844
int id = Profiler::instance()->lookupClass(name_slice, name_len);
811845
if (id != -1) {
812-
jlong class_tag = nextClassTag();
813-
if (jvmti->SetTag(klass, class_tag) == JVMTI_ERROR_NONE) {
846+
// Reuse the existing tag if this class was already tagged by a
847+
// prior generation - only the resolved id needs refreshing,
848+
// not the tag identity heapReferenceCallback() keys off of.
849+
jlong class_tag = tag != 0 ? tag : nextClassTag();
850+
if (tag != 0 ||
851+
jvmti->SetTag(klass, class_tag) == JVMTI_ERROR_NONE) {
814852
_class_tags.insert(class_tag, (u32)id);
815853
}
816854
}
@@ -965,6 +1003,10 @@ jint JNICALL ReferenceChainTracker::heapReferenceCallback(
9651003
}
9661004
*tag_ptr = tag;
9671005
ctx->edges_admitted++;
1006+
// Queue for expandFrontier()/markAllFrontierExpanded() - see
1007+
// _pending_expand's own declaration comment for why this replaces a
1008+
// scan over the admitted range.
1009+
ctx->tracker->_pending_expand.push_back(tag);
9681010
}
9691011

9701012
return JVMTI_VISIT_OBJECTS;
@@ -975,15 +1017,10 @@ jint JNICALL ReferenceChainTracker::heapReferenceCallback(
9751017
// ---------------------------------------------------------------------------
9761018

9771019
void ReferenceChainTracker::markAllFrontierExpanded() {
978-
jlong scan_limit = _frontier->size();
979-
for (jlong tag = 1; tag <= scan_limit; tag++) {
980-
FrontierEntry entry{};
981-
if (_frontier->lookup(tag, &entry) &&
982-
entry.state == FrontierEntryState::FRONTIER) {
983-
_frontier->markExpanded(tag);
984-
}
1020+
while (!_pending_expand.empty()) {
1021+
_frontier->markExpanded(_pending_expand.front());
1022+
_pending_expand.pop_front();
9851023
}
986-
_expand_cursor = scan_limit + 1;
9871024
}
9881025

9891026
void ReferenceChainTracker::expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni,
@@ -1012,29 +1049,26 @@ void ReferenceChainTracker::expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni,
10121049
while (!ctx.truncated && progress) {
10131050
progress = false;
10141051

1015-
// Re-read size() every outer iteration: expanding one entry can insert
1016-
// new FRONTIER children with higher tags, extending the range this loop
1017-
// needs to cover before this pass's budget runs out (design doc
1018-
// Algorithm step 3/4 - admitting them immediately, rather than waiting
1019-
// for a whole extra pass, is a strictly tighter use of the budget this
1020-
// call already has).
1021-
jlong scan_limit = _frontier->size();
1022-
if (_expand_cursor > scan_limit) {
1052+
if (_pending_expand.empty()) {
10231053
break; // nothing pending
10241054
}
10251055

1026-
std::vector<jlong> candidate_tags;
1027-
for (jlong tag = _expand_cursor; tag <= scan_limit; tag++) {
1028-
FrontierEntry entry{};
1029-
if (_frontier->lookup(tag, &entry) &&
1030-
entry.state == FrontierEntryState::FRONTIER) {
1031-
candidate_tags.push_back(tag);
1032-
}
1033-
}
1034-
if (candidate_tags.empty()) {
1035-
_expand_cursor = scan_limit + 1;
1036-
break; // caught up; nothing pending
1037-
}
1056+
// Snapshot at most `budget` pending tags per iteration, not the whole
1057+
// queue: _pending_expand can hold far more than this call could ever
1058+
// admit (e.g. every hop_cap-limited entry from a huge one-shot first
1059+
// pass), and GetObjectsWithTags()'s own cost scales with the batch size
1060+
// handed to it - resolving the entire backlog up front just to abandon
1061+
// most of it once `budget` is hit would reintroduce the same
1062+
// admitted-range-sized cost this queue was meant to avoid. Entries
1063+
// expanded below can push new FRONTIER children onto the back of
1064+
// _pending_expand (design doc Algorithm step 3/4 - admitting them
1065+
// immediately, rather than waiting for a whole extra pass, is a
1066+
// strictly tighter use of the budget this call already has); those wait
1067+
// for a later outer-loop iteration.
1068+
size_t batch_size =
1069+
std::min(_pending_expand.size(), (size_t)std::max(budget, 1));
1070+
std::vector<jlong> candidate_tags(_pending_expand.begin(),
1071+
_pending_expand.begin() + batch_size);
10381072

10391073
// Design doc Algorithm step 2: "resolve currently-live tagged frontier
10401074
// objects" - batched via one GetObjectsWithTags call rather than one
@@ -1064,14 +1098,18 @@ void ReferenceChainTracker::expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni,
10641098
if (ctx.truncated) {
10651099
break; // budget/frontier-cap already hit by a sibling in this batch
10661100
}
1101+
// `tag` is always _pending_expand's current front here: candidate_tags
1102+
// is a front-to-back snapshot, and nothing removes from the front
1103+
// except this loop itself - concurrent admissions (below) only append
1104+
// to the back.
10671105
auto it = live.find(tag);
10681106
if (it == live.end()) {
10691107
// Design doc Algorithm step 2: "objects that fail to resolve are
10701108
// dropped (dead - free pruning)". The object died between passes;
10711109
// JVMTI already forgot its tag along with it, so there is nothing
10721110
// left to expand and no live object to release a tag on.
10731111
_frontier->clear(tag);
1074-
_expand_cursor = tag + 1;
1112+
_pending_expand.pop_front();
10751113
progress = true;
10761114
continue;
10771115
}
@@ -1087,14 +1125,15 @@ void ReferenceChainTracker::expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni,
10871125
if (follow_err != JVMTI_ERROR_NONE || ctx.truncated) {
10881126
// Either this call failed outright, or ran into the budget/
10891127
// frontier-cap mid-way through this entry's own children. Leave
1090-
// `tag` FRONTIER (not EXPANDED) so a later pass retries it -
1091-
// already-admitted children stay tagged/in the table, and
1092-
// heapReferenceCallback()'s *tag_ptr == 0 check makes re-expanding
1093-
// this entry idempotent, so no work is lost, only deferred.
1128+
// `tag` at the front of _pending_expand (still FRONTIER, not
1129+
// EXPANDED) so a later pass retries it - already-admitted children
1130+
// stay tagged/in the table, and heapReferenceCallback()'s
1131+
// *tag_ptr == 0 check makes re-expanding this entry idempotent, so
1132+
// no work is lost, only deferred.
10941133
break;
10951134
}
10961135
_frontier->markExpanded(tag);
1097-
_expand_cursor = tag + 1;
1136+
_pending_expand.pop_front();
10981137
progress = true;
10991138
}
11001139

@@ -1237,6 +1276,10 @@ bool ReferenceChainTracker::runPass(jvmtiEnv *jvmti, JNIEnv *jni,
12371276
// unavailable/disabled, so this is a strict upgrade with no behavior change
12381277
// on hosts without a usable timestamp counter.
12391278
u64 pass_wall_ticks = 0;
1279+
// Captured before _search_started flips below - see its use at
1280+
// updatePacing() further down for why the first pass's own duration must
1281+
// not feed that controller the same way every later pass's does.
1282+
bool was_first_pass = !_search_started;
12401283

12411284
if (!_search_started) {
12421285
_search_started = true;
@@ -1246,7 +1289,7 @@ bool ReferenceChainTracker::runPass(jvmtiEnv *jvmti, JNIEnv *jni,
12461289
ctx.tracker = this;
12471290
ctx.frontier = _frontier;
12481291
ctx.hop_cap = _hop_cap;
1249-
ctx.budget = _effective_budget;
1292+
ctx.budget = _first_pass_budget;
12501293
ctx.edges_admitted = 0;
12511294
ctx.truncated = false;
12521295
ctx.frontier_cap_hit = false;
@@ -1320,7 +1363,15 @@ bool ReferenceChainTracker::runPass(jvmtiEnv *jvmti, JNIEnv *jni,
13201363
store(_passes_run, load(_passes_run) + 1);
13211364
_last_pass_gc_finish_epoch = gcFinishEpoch();
13221365
store(_last_pass_ns, OS::nanotime());
1323-
updatePacing(pass_wall_ticks);
1366+
if (!was_first_pass) {
1367+
// The first pass spends _first_pass_budget, not _effective_budget - its
1368+
// duration is not a signal about the per-pass cost updatePacing() is
1369+
// trying to regulate (expandFrontier()'s cheap, per-node expansion
1370+
// calls), so feeding it in here would throttle _effective_budget down
1371+
// for every one of those unrelated later passes based on a single,
1372+
// deliberately oversized outlier.
1373+
updatePacing(pass_wall_ticks);
1374+
}
13241375
// Search restart (this class's own header comment): accumulate this
13251376
// pass's own cost toward the running total restartSearch() will spend into
13261377
// _pain_budget once the search reaches a terminal state - same

0 commit comments

Comments
 (0)