diff --git a/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadMetric.kt b/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadMetric.kt index da8023d..fc2b49d 100644 --- a/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadMetric.kt +++ b/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadMetric.kt @@ -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(val name: String, val metricProvider: () -> T): Gauge { @@ -26,24 +30,51 @@ sealed class SystemLoadMetric(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("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("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 = CopyOnWriteArrayList() + + // Per-PID snapshot used by the background poller between its own ticks. + private var pollerLastWallNanos: Long = System.nanoTime() + private var pollerLastSnapshot: MutableMap = 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 { @@ -57,19 +88,56 @@ sealed class SystemLoadMetric(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() + 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() @@ -90,31 +158,32 @@ sealed class SystemLoadMetric(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 { + val snapshot = mutableMapOf() + 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 MetricRegistry.register(metric: SystemLoadMetric) { register(metric.name, metric) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadService.kt b/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadService.kt index 4052f83..7442f93 100644 --- a/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadService.kt +++ b/src/main/kotlin/com/smplio/gradle/build/insights/modules/load/SystemLoadService.kt @@ -10,7 +10,8 @@ import java.util.concurrent.TimeUnit abstract class SystemLoadService: BuildService, OperationCompletionListener, - ISystemLoadReportProvider + ISystemLoadReportProvider, + AutoCloseable { private val registry: MetricRegistry = MetricRegistry() @@ -39,4 +40,8 @@ abstract class SystemLoadService: BuildService, override fun provideSystemLoadReport(): SystemLoadReport? { return metricsReporter.provideSystemLoadReport() } + + override fun close() { + SystemLoadMetric.CpuLoadSampler.instance.shutdown() + } } \ No newline at end of file diff --git a/src/main/resources/build_charts.js b/src/main/resources/build_charts.js index 1bd3669..0c83de0 100644 --- a/src/main/resources/build_charts.js +++ b/src/main/resources/build_charts.js @@ -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: {