Per-sampler timing instrumentation, compiled in only under an opt-in build flag.
Answers "how much does each sampler cost per sample?" — a question the existing
unwinding_ticks_* counters cannot, because they measure the shared stack
unwinder rather than any individual sampler.
./gradlew buildDebug -PenableSamplerPerf
./gradlew buildRelease -PenableSamplerPerfThe property appends -D__SAMPLER_PERF__ to the release and debug
configurations only (ConfigurationPresets.kt). It is deliberately not applied
to asan/tsan/fuzzer: those configs instrument every memory access, so the
per-sample times would describe the sanitizer rather than the sampler. gtest
builds inherit the flag automatically, since GtestTaskBuilder starts from the
configuration's own compilerArgs.
Without the flag the feature is entirely absent: SAMPLER_PERF_PROBE() expands
to ((void)0), SamplerPerfProbe does not exist, SamplerPerf::report() is an
empty function, and the counters occupy no enum slot and no storage in
DD_COUNTER_TABLE — they never appear in JFR or in
JavaProfiler.getDebugCounters().
SamplerPerf::report() runs from Profiler::stop(), before the JFR recording is
stopped, and prints to stdout. Samplers with a zero sample count are omitted, so
only engines that actually ran are listed:
[java-profiler] sampler performance (elapsed wall time, not CPU time):
[java-profiler] sampler total (ms) count avg (ns)
[java-profiler] cpu 1204.518 61243 19668
[java-profiler] wallclock 338.902 20104 16857
[java-profiler] note: cpu includes unwinding_ticks_async, and alloc includes liveness; do not sum them.
Because the counters live in the ordinary counter table, the same values also
flow out through the two existing sinks when the flag is on: the JFR
datadog.ProfilerCounter events (sampler_ticks.cpu, sampler_count.cpu, …)
and JavaProfiler.getDebugCounters(). The average is derived, not stored.
| Slot | Engines sharing it | Probe site |
|---|---|---|
cpu |
PerfEvents, CTimer, CTimerJvmti, ITimer, ITimerJvmti |
each engine's signalHandler |
wallclock |
WallClockASGCT, WallClockJvmti, J9WallClock |
sharedSignalHandler; for J9, the per-thread body of timerLoop |
alloc |
ObjectSampler |
recordAllocation |
liveness |
LivenessTracker |
track |
nativemem |
MallocTracer |
recordMalloc |
nativesocket |
NativeSocketSampler |
recordEvent |
Only one CPU engine and one wallclock engine is ever live in a run
(Profiler::selectCpuEngine / selectWallEngine), so the variants share a slot;
per-variant slots would only ever have one of them non-zero.
There is no lock/monitor or exception sampler in this fork — the BCI_LOCK /
BCI_PARK plumbing exists but no native call site records such a sample — so
there are no slots for them.
Five things are easy to get wrong:
-
sampler_ticks.*is elapsed wall time, not CPU time. It includes time the sampled thread spent descheduled, page-faulting, or waiting on theProfiler::_locks[]stripe. That is the right metric for the latency the sampler adds to the sampled thread, but it is not the sampler's CPU cost, and it inflates under load. If an average looks implausibly large, suspect lock-stripe contention inProfiler::recordSamplebefore suspecting the unwinder. -
sampler_ticks.cpualready containsunwinding_ticks_async. Both time the same unwinder. Never sum them; the unwinding counter is a strict subset and is the first thing to compare against. -
sampler_ticks.allocalready containssampler_ticks.liveness, becauseLivenessTracker::track()is called fromObjectSampler::recordAllocation(). Liveness is a nested breakdown of alloc, not a sibling to add to it. -
Cross-sampler nesting slightly double-counts.
SIGNAL_HANDLER_GUARD_OR_DROP()drops only when there is noProfiledThread; it does not reject nested signals. A wallclock signal landing inside a CPU handler is therefore counted in both totals. The distortion is bounded and small — the nested handler bails almost immediately at itsCriticalSection— but it is not zero. -
The count includes samples that bailed out (reentrancy reject, disabled engine, init-window tick), which dilutes the average downward. Foreign signals are excluded by construction: each probe is declared after its handler's signal-origin check, so a stray
SIGPROF— and the foreign handler thatOS::forwardForeignSignal()runs for it — is never timed as our work.ITimeris the exception, having no origin check to sit behind (see its own NOTE initimer.cpp). Use theSAMPLES_DROPPED_*/WC_SIGNAL_*counters to quantify the bail-outs. -
report()can undercount the very last sample of a run. The probe is declared beforeInflightGuard(see caveat 5), so at handler exitInflightGuard's destructor — whichProfiler::stop()'s drain waits on — runs before the probe's destructor, which is the one that updates the counters. A handler still unwinding when the drain observes zero can have its counters land afterreport()already read them. The teardown work between the drain andreport()dwarfs the few instructions between those two destructors, so this is a real but vanishingly unlikely race, and its only effect is under-reporting by at most one sample.
SAMPLER_PERF_PROBE(SP_CPU) declares a SamplerPerfProbe: the constructor takes
a os::nanotime() timestamp, the destructor adds the elapsed ticks to
sampler_ticks.<name> and bumps sampler_count.<name>.
- Why RAII rather than a manual start/stop pair. Every instrumented handler has several early returns, so a manual pair would have to be repeated at each exit and would silently miss any newly added one.
- Async-signal safety. All probe state is on the stack. No
thread_local— one here would risk the lazy DTV-slotmallocthat deadlocked against the JVMCI compiler on Graal aarch64 (seeguards.h). The destructor only readsOS::nanotime()and issues two relaxed atomic adds, which land on separate cache lines becauseCounterspads every slot to 128 bytes. - Guard ordering. The probe is declared after any
ErrnoPreserver, which must stay the first-declared local so it destructs last —OS::nanotime()callsclock_gettime(), which may seterrno. InPerfEvents::signalHandlerthe probe sits betweenErrnoPreserverandPerfFdRearmGuardon purpose, so the guard's rearmioctls are inside the measured window. - Crash recovery does not lose samples. The
sigsetjmplanding pad is insideHotspotSupport::walkJavaStack, which the handler calls viarecordSample, so acheckFault→siglongjmpunwinds only to that frame — above the probe. The destructor is not bypassed. - Layout coupling. The probe addresses its two slots as
SAMPLER_TICKS_CPU + 2*id(+1 for the count), the same base-plus-offset scheme theDICTIONARY_*group uses.samplerPerf.hgeneratesstatic_asserts fromDD_SAMPLER_LISTthat pin this layout, so reorderingDD_COUNTER_TABLE_SAMPLER_PERFincounters.h— or inserting an unrelated counter between its entries — is a compile error rather than silent misattribution.
Converting ticks to nanoseconds uses SamplerPerf::ticksToNanos(), which splits
into whole seconds plus remainder. The naive ticks * 1e9 / frequency()
overflows u64 well inside a normal recording: at 3 GHz, one hour is ~1.08e13
ticks and ticks * 1e9 is ~1.08e22, so it silently reports 2.88 seconds instead
of 3600.
- Add an entry to
DD_SAMPLER_LISTinsamplerPerf.h. - Add the matching
sampler_ticks.<name>/sampler_count.<name>pair toDD_COUNTER_TABLE_SAMPLER_PERFincounters.h, in the same position and with ticks before count. Thestatic_asserts will fail the build if they disagree. - Place
SAMPLER_PERF_PROBE(SP_<ID>)at the sampler's entry point, after anyErrnoPreserverand after its signal-origin check.
ddprof-lib/src/test/cpp/samplerPerf_ut.cpp is dual-mode, following
faultInjection_ut.cpp. Without the flag it asserts the probe is inert and that
no sampler_* counter exists in the table — the guard that protects production.
With the flag it covers the offset addressing, accumulation, counting on early
return, cross-sampler nesting, and the ticksToNanos overflow case.
./gradlew :ddprof-lib:gtestDebug # inert-build assertions
./gradlew :ddprof-lib:gtestDebug -PenableSamplerPerf # enabled-build assertions