Skip to content

Commit 5597be9

Browse files
gnurizenclaude
andcommitted
Replay USDT metadata on semaphore-count increase, not every call
73c1f64 made emitMetadata re-fire stall_reason_map and cubin_loaded on every CUPTI callback when the per-probe semaphore was nonzero, to fix multi-consumer attach: if a second consumer (e.g. test/bpf/activity_parser) joins while parca-agent is already attached, the semaphore stays nonzero across the 1→2 transition, so a "fire on transition to nonzero" gate never re-fires and the second consumer permanently misses metadata. The unconditional re-fire turned out to be a sledgehammer: at PyTorch-class CUDA call rates (~10K callbacks/sec × L loaded cubins × ENTER+EXIT) it emits hundreds of thousands of cubin_loaded events per second. Downstream, each one drives a /proc/<pid>/mem cubin read in the parca-agent consumer — observed driving the agent heap to 38GB before OOM. The USDT semaphore is a refcount: every uprobe attach increments, every detach decrements. Track the raw count and re-emit whenever the current count exceeds the previously observed count. That handles 0→1 (cold start) and 1→2 (second consumer joins mid-run) without firing every call. ABA — attach+detach that net the same count between two of our reads — is bounded by a 30s periodic refresh that also re-emits any cubin whose lastEmittedNs has gone stale. Steady-state cost in emitMetadata: two atomic uint16 loads, one exchange, a branch. No mutex acquisition, no USDT fires. Pair with BPF-side (pid, crc) dedup in the consumer to make the 30s-refresh fires no-ops once a tracer has seen each cubin. Signed-off-by: Tommy Reilly <gnurizen@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f30e18f commit 5597be9

2 files changed

Lines changed: 66 additions & 15 deletions

File tree

src/pc_sampling.cpp

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -614,27 +614,67 @@ void PCSampling::processPCSamplingData(ConfigureData *configureData) {
614614
}
615615
}
616616

617+
// Period between forced metadata refreshes regardless of probe-arm
618+
// transitions. Bounds how long an undetected ABA semaphore cycle (consumer
619+
// detaches and re-attaches between two of our reads) can leave a tracer
620+
// missing cubins or the stall-reason map.
621+
static constexpr uint64_t kEmitRefreshPeriodNs = 30ULL * 1000 * 1000 * 1000;
622+
623+
static uint64_t emitMetadataNowNs() {
624+
struct timespec ts;
625+
clock_gettime(CLOCK_MONOTONIC, &ts);
626+
return uint64_t(ts.tv_sec) * 1000000000ULL + uint64_t(ts.tv_nsec);
627+
}
628+
629+
// Read the raw USDT semaphore (a refcount of attached uprobe consumers).
630+
// __atomic_load_n with relaxed ordering — the kernel updates this from
631+
// another address space via RefCtrOffset; this prevents the compiler from
632+
// caching the value across calls.
633+
static inline uint16_t readSem(unsigned short &sem) {
634+
return __atomic_load_n(reinterpret_cast<uint16_t *>(&sem), __ATOMIC_RELAXED);
635+
}
636+
617637
void PCSampling::emitMetadata() {
618-
// Re-fire stall_reason_map and cubin_loaded on every call when a tracer is
619-
// attached. The naive "fire once on semaphore-transition" approach was
620-
// racy: there is a small window between the kernel incrementing the USDT
621-
// semaphore (via RefCtrOffset on uprobe attach) and the breakpoint
622-
// actually being armed in the target's text, during which a single fire
623-
// from the lib hits the original nop and is silently lost. Re-firing
624-
// every call closes that race.
638+
// Re-emit stall_reason_map and cubin_loaded for late-attaching tracers.
625639
//
626-
// Both consumers de-duplicate downstream: the BPF stall_reason_map
627-
// handler returns early after the first successful load (stall_map_loaded
628-
// map), and cubin consumers track cubins by CRC.
629-
if (stallReasonMap.data() && PARCAGPU_STALL_REASON_MAP_ENABLED()) {
640+
// The USDT semaphore is a refcount of attached uprobe consumers — every
641+
// attach increments, every detach decrements. Re-emit whenever the count
642+
// increases (so 1→2 fires the metadata to a second consumer joining
643+
// while the first is still attached), plus on a periodic refresh that
644+
// bounds staleness from ABA cycles which net the same count between our
645+
// reads.
646+
647+
const uint64_t nowNs = emitMetadataNowNs();
648+
const uint64_t lastRefresh = lastRefreshNs.load(std::memory_order_relaxed);
649+
const bool refresh = (nowNs - lastRefresh) > kEmitRefreshPeriodNs;
650+
if (refresh) {
651+
lastRefreshNs.store(nowNs, std::memory_order_relaxed);
652+
}
653+
654+
// Stall reason map (small, ~4KB) — no per-entry state needed.
655+
const uint16_t stallSem = readSem(parcagpu_stall_reason_map_semaphore);
656+
const uint16_t prevStall =
657+
prevStallSem.exchange(stallSem, std::memory_order_acq_rel);
658+
const bool stallJoin = stallSem > prevStall;
659+
if (stallReasonMap.data() && stallSem > 0 && (stallJoin || refresh)) {
630660
PARCAGPU_STALL_REASON_MAP(stallReasonMap.data(),
631661
stallReasonMap.numEntries());
632662
}
633663

634-
if (PARCAGPU_CUBIN_LOADED_ENABLED()) {
664+
// Cubin loaded — re-fire all cubins on a consumer-join (count increase);
665+
// on a periodic refresh, re-fire only entries that have gone stale.
666+
const uint16_t cubinSem = readSem(parcagpu_cubin_loaded_semaphore);
667+
const uint16_t prevCubin =
668+
prevCubinSem.exchange(cubinSem, std::memory_order_acq_rel);
669+
const bool cubinJoin = cubinSem > prevCubin;
670+
if (cubinSem > 0 && (cubinJoin || refresh)) {
635671
std::lock_guard<std::mutex> lock(contextMutex);
636-
for (const auto &ref : loadedCubins) {
637-
fireCubinLoaded(ref.crc, ref.data, ref.size);
672+
for (auto &ref : loadedCubins) {
673+
if (cubinJoin ||
674+
(nowNs - ref.lastEmittedNs) > kEmitRefreshPeriodNs) {
675+
fireCubinLoaded(ref.crc, ref.data, ref.size);
676+
ref.lastEmittedNs = nowNs;
677+
}
638678
}
639679
}
640680
}
@@ -759,10 +799,11 @@ void PCSampling::loadModule(const char *cubin, size_t cubinSize) {
759799
cubinData->cubin = cubin;
760800
cubinCrcToCubinData[cubinCrc].second = 1;
761801
DEBUG_PRINTF("Module 0x%lx loaded (new)\n", cubinCrc);
802+
const uint64_t emittedNs = emitMetadataNowNs();
762803
fireCubinLoaded(cubinCrc, cubin, cubinSize);
763804
{
764805
std::lock_guard<std::mutex> lock(contextMutex);
765-
loadedCubins.push_back({cubinCrc, cubin, cubinSize});
806+
loadedCubins.push_back({cubinCrc, cubin, cubinSize, emittedNs});
766807
}
767808
}
768809
}

src/pc_sampling.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,18 @@ class PCSampling {
159159
uint64_t crc;
160160
const char *data;
161161
size_t size;
162+
uint64_t lastEmittedNs; // monotonic; for emitMetadata staleness check
162163
};
163164
std::vector<CubinRef> loadedCubins;
165+
166+
// emitMetadata tracking: per-probe USDT semaphore count from the last
167+
// call. Re-emit fires when the current count exceeds this (a new
168+
// consumer joined, even if another was already attached). A periodic
169+
// refresh bounds staleness from ABA cycles that net the same count
170+
// between our reads.
171+
std::atomic<uint16_t> prevStallSem{0};
172+
std::atomic<uint16_t> prevCubinSem{0};
173+
std::atomic<uint64_t> lastRefreshNs{0};
164174
};
165175

166176
// Fire the error USDT probe. Callable from any translation unit.

0 commit comments

Comments
 (0)