Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import com.codahale.metrics.Gauge
import com.codahale.metrics.MetricRegistry
import java.lang.management.ManagementFactory
import java.time.Duration
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit

sealed class SystemLoadMetric<T: Number>(val name: String, val metricProvider: () -> T): Gauge<T> {

Expand All @@ -26,24 +30,51 @@ sealed class SystemLoadMetric<T: Number>(val name: String, val metricProvider: (

// CPU usage (percent) for everything running inside the current Gradle JVM (100% equals one fully utilized core)
class GradleJvmCpuPercentMetric : SystemLoadMetric<Double>("gradleJvmCpuPercent", {
CpuLoadSampler.sampleJvmProcessCpuPercent()
CpuLoadSampler.instance.sampleJvmProcessCpuPercent()
})

// CPU usage (percent) aggregated across all descendant processes started by Gradle that run outside this JVM (100% equals one fully utilized core)
class GradleDescendantsCpuPercentMetric : SystemLoadMetric<Double>("gradleDescendantsCpuPercent", {
CpuLoadSampler.sampleChildrenCpuPercent()
CpuLoadSampler.instance.sampleChildrenCpuPercent()
})

private object CpuLoadSampler {
/**
* Manages CPU sampling for both the Gradle JVM process and its descendants.
*
* Descendants are sampled at a higher frequency (every 500 ms) on a dedicated background
* thread so that short-lived child processes (Kotlin/Java compiler daemons, workers, etc.)
* are not missed between the 5-second reporting ticks. Each time the main reporter calls
* [sampleChildrenCpuPercent] it receives the **average** of all sub-samples collected since
* the previous call, then the buffer is cleared for the next window.
*/
class CpuLoadSampler private constructor() {

private val processors: Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)

// JVM process sampling state
private var lastWallTimeJvmNanos: Long = System.nanoTime()
private var lastCpuTimeJvmNanos: Long = currentJvmProcessCpuTimeNanos()
// ---- JVM process sampling state ----
@Volatile private var lastWallTimeJvmNanos: Long = System.nanoTime()
@Volatile private var lastCpuTimeJvmNanos: Long = currentJvmProcessCpuTimeNanos()

// ---- High-frequency children sampling (500 ms) ----
// Each background tick appends one Double (CPU %) to this list.
// sampleChildrenCpuPercent() drains and averages the list every ~5 s.
private val childrenSamples: CopyOnWriteArrayList<Double> = CopyOnWriteArrayList()

// Per-PID snapshot used by the background poller between its own ticks.
private var pollerLastWallNanos: Long = System.nanoTime()
private var pollerLastSnapshot: MutableMap<Long, Long> = snapshotChildrenCpu()
private val pollerLock = Any()

private val poller: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "gradle-insights-children-cpu-poller").also { it.isDaemon = true }
}

init {
poller.scheduleAtFixedRate(::pollChildren, 500, 500, TimeUnit.MILLISECONDS)
}

// Children sampling state
private var lastWallTimeChildrenNanos: Long = System.nanoTime()
private var lastCpuTimeChildrenNanos: Long = currentChildrenCpuTimeNanos()
// ---- Public API ----

@Synchronized
fun sampleJvmProcessCpuPercent(): Double {
Expand All @@ -57,19 +88,56 @@ sealed class SystemLoadMetric<T: Number>(val name: String, val metricProvider: (
return pct.coerceIn(0.0, 100.0 * processors)
}

@Synchronized
/**
* Returns the average children CPU % across all 500 ms sub-samples collected since
* the previous call to this method. The sample buffer is atomically drained on each
* call so there is no double-counting between 5-second reporting windows.
*/
fun sampleChildrenCpuPercent(): Double {
val now = System.nanoTime()
val cpuNow = currentChildrenCpuTimeNanos()
val deltaCpu = (cpuNow - lastCpuTimeChildrenNanos).coerceAtLeast(0L)
val deltaWall = (now - lastWallTimeChildrenNanos).coerceAtLeast(1L)
lastCpuTimeChildrenNanos = cpuNow
lastWallTimeChildrenNanos = now
val pct = (deltaCpu.toDouble() / deltaWall.toDouble()) * 100.0
// With 100% meaning one full core, allow up to cores*100%
return pct.coerceIn(0.0, 100.0 * processors)
// Atomically drain the buffer accumulated by the background poller.
val drained = mutableListOf<Double>()
val iter = childrenSamples.iterator()
while (iter.hasNext()) drained.add(iter.next())
childrenSamples.removeAll(drained.toSet())

if (drained.isEmpty()) return 0.0
return drained.average().coerceIn(0.0, 100.0 * processors)
}

fun shutdown() {
poller.shutdown()
try {
poller.awaitTermination(2, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}

// ---- Background poller (runs every 500 ms) ----

private fun pollChildren() {
val pct = synchronized(pollerLock) {
val now = System.nanoTime()
val deltaWall = (now - pollerLastWallNanos).coerceAtLeast(1L)
pollerLastWallNanos = now

val currentSnapshot = snapshotChildrenCpu()

var deltaCpu = 0L
for ((pid, cpuNow) in currentSnapshot) {
val cpuBefore = pollerLastSnapshot[pid] ?: 0L
val d = cpuNow - cpuBefore
if (d > 0L) deltaCpu += d
}
pollerLastSnapshot = currentSnapshot

(deltaCpu.toDouble() / deltaWall.toDouble()) * 100.0
}
childrenSamples.add(pct.coerceIn(0.0, 100.0 * processors))
}

// ---- Helpers ----

private fun currentJvmProcessCpuTimeNanos(): Long {
// Prefer com.sun.management.OperatingSystemMXBean for precise JVM CPU time
val osBean = ManagementFactory.getOperatingSystemMXBean()
Expand All @@ -90,31 +158,32 @@ sealed class SystemLoadMetric<T: Number>(val name: String, val metricProvider: (
}
}

private fun currentChildrenCpuTimeNanos(): Long {
return try {
var sum = 0L
val current = ProcessHandle.current()
// Use descendants to include nested children
current.descendants().forEach { descendant ->
/** Returns a map of PID → totalCpuDuration nanos for all current descendants. */
private fun snapshotChildrenCpu(): MutableMap<Long, Long> {
val snapshot = mutableMapOf<Long, Long>()
try {
ProcessHandle.current().descendants().forEach { descendant ->
try {
val d: Duration? = descendant.info().totalCpuDuration().orElse(null)
if (d != null) {
sum += d.toNanos()
snapshot[descendant.pid()] = d.toNanos()
}
} catch (e: Throwable) {
// ignore processes we cannot inspect
e.printStackTrace()
} catch (_: Throwable) {
// Process may have exited between enumeration and inspection — ignore.
}
}
sum
} catch (e: Throwable) {
e.printStackTrace()
0L
}
return snapshot
}

companion object {
val instance: CpuLoadSampler by lazy { CpuLoadSampler() }
}
}
}

fun <T: Number> MetricRegistry.register(metric: SystemLoadMetric<T>) {
register(metric.name, metric)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import java.util.concurrent.TimeUnit

abstract class SystemLoadService: BuildService<BuildServiceParameters.None>,
OperationCompletionListener,
ISystemLoadReportProvider
ISystemLoadReportProvider,
AutoCloseable
{

private val registry: MetricRegistry = MetricRegistry()
Expand Down Expand Up @@ -39,4 +40,8 @@ abstract class SystemLoadService: BuildService<BuildServiceParameters.None>,
override fun provideSystemLoadReport(): SystemLoadReport? {
return metricsReporter.provideSystemLoadReport()
}

override fun close() {
SystemLoadMetric.CpuLoadSampler.instance.shutdown()
}
}
19 changes: 10 additions & 9 deletions src/main/resources/build_charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,23 @@ new Chart(cpuChart, {
data: {
labels: labels.map(value => Math.round((value - minTime) / 1000) + 's'),
datasets: [
{
label: 'Gradle CPU usage',
data: gradleJvmCpuPercentMetrics,
fill: true,
borderColor: 'rgb(85,160,223)',
backgroundColor: 'rgb(85,180,223)',
tension: 0.1
},

{
label: 'Children CPU usage',
data: gradleDescendantsCpuPercentMetrics,
fill: true,
borderColor: 'rgb(84,105,220)',
backgroundColor: 'rgb(84,125,220)',
backgroundColor: 'rgba(84,125,220, 0.5)',
tension: 0.1,
},
{
label: 'Gradle CPU usage',
data: gradleJvmCpuPercentMetrics,
fill: true,
borderColor: 'rgb(85,160,223)',
backgroundColor: 'rgba(85,180,223, 0.5)',
tension: 0.1
},
],
},
options: {
Expand Down
Loading