karthik.dev
Back to blogAndroid

GPU Insight AI: Building an Android GPU Monitor with Gemini AI Diagnostics

2026-08-03 · 4 min read

The Gap in Android Developer Tools

Android developers building GPU-intensive apps — games, ML inference, image processing — have no native tool to monitor GPU utilization, temperature, or memory in real time. The Android GPU Inspector exists but requires a separate desktop connection. When you're iterating on a mobile ML feature, you want telemetry directly on the device.

GPU Insight AI fills that gap: a self-contained Android app that monitors GPU hardware and uses Gemini AI to turn raw numbers into actionable diagnostics.

What It Monitors

Metric Description
GPU Utilization % How much of GPU compute capacity is being used
GPU Temperature In Celsius — critical for detecting thermal throttling
GPU Memory Used / Total VRAM consumption
GPU Clock Speed Current vs max clock — drops indicate throttling
CPU-GPU Transfer Rate Data movement between CPU and GPU memory

Architecture

Background Service (Android Service)
    ↓ every 500ms
GPU Telemetry Collector
    ├── reads /sys/class/devfreq/* (clock speed, utilization)
    ├── reads /sys/class/thermal/thermal_zone* (temperature)
    └── reads /proc/meminfo + GPU-specific nodes
    ↓
Room Database (historical metrics)
    ↓
Jetpack Compose UI (real-time dashboard)
    ↓ on anomaly or user request
Gemini AI Diagnostic Engine

Telemetry Collection

The tricky part: Android doesn't expose a unified GPU telemetry API. Different manufacturers expose metrics through different /sys paths:

class GpuTelemetryCollector {
    
    fun readGpuUtilization(): Float? {
        // Try Qualcomm Adreno path first
        val adrenoPath = "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage"
        // Then Mali path
        val maliPath = "/sys/class/devfreq/mali/load"
        // Then generic devfreq
        val genericPath = "/sys/class/devfreq/gpufreq/cur_load"
        
        return readSysFile(adrenoPath)?.toFloatOrNull()
            ?: readSysFile(maliPath)?.let { parseLoad(it) }
            ?: readSysFile(genericPath)?.toFloatOrNull()
    }
    
    fun readGpuTemperature(): Float? {
        // Scan thermal zones for GPU sensor
        val thermalDir = File("/sys/class/thermal")
        thermalDir.listFiles()?.forEach { zone ->
            val type = zone.resolve("type").readTextOrNull() ?: return@forEach
            if (type.contains("gpu", ignoreCase = true) || 
                type.contains("tsens_tz_sensor", ignoreCase = true)) {
                return zone.resolve("temp").readTextOrNull()
                    ?.toFloatOrNull()
                    ?.div(1000f)  // convert millidegrees to Celsius
            }
        }
        return null
    }
}

Room Database for Historical Analysis

@Entity(tableName = "gpu_metrics")
data class GpuMetricEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val timestamp: Long = System.currentTimeMillis(),
    val utilizationPercent: Float?,
    val temperatureCelsius: Float?,
    val memoryUsedMb: Long?,
    val memoryTotalMb: Long?,
    val clockSpeedMhz: Long?,
)

@Dao
interface GpuMetricDao {
    @Insert
    suspend fun insert(metric: GpuMetricEntity)
    
    @Query("SELECT * FROM gpu_metrics WHERE timestamp > :since ORDER BY timestamp ASC")
    fun getMetricsSince(since: Long): Flow<List<GpuMetricEntity>>
    
    @Query("""
        SELECT AVG(utilizationPercent), MAX(temperatureCelsius), AVG(clockSpeedMhz)
        FROM gpu_metrics 
        WHERE timestamp > :since
    """)
    suspend fun getAggregates(since: Long): MetricAggregates
}

Gemini AI Diagnostics

When the user taps "Diagnose" (or when an anomaly is automatically detected), the current metrics + recent history are sent to Gemini:

suspend fun diagnosePerformance(
    currentMetrics: GpuMetrics,
    history: List<GpuMetrics>,
): DiagnosticResult {
    val prompt = buildString {
        append("You are an Android GPU performance expert. Analyze these metrics:\n\n")
        append("Current Metrics:\n")
        append("- GPU Utilization: ${currentMetrics.utilizationPercent}%\n")
        append("- Temperature: ${currentMetrics.temperatureCelsius}°C\n")
        append("- Clock Speed: ${currentMetrics.clockSpeedMhz} MHz (max: ${currentMetrics.maxClockMhz} MHz)\n")
        append("- Memory: ${currentMetrics.memoryUsedMb}MB / ${currentMetrics.memoryTotalMb}MB\n\n")
        
        append("Recent History (last 30 seconds):\n")
        history.takeLast(60).forEach { m ->
            append("${m.timestamp}: util=${m.utilizationPercent}%, temp=${m.temperatureCelsius}°C, clock=${m.clockSpeedMhz}MHz\n")
        }
        
        append("\nIdentify: (1) any thermal throttling, (2) memory pressure, (3) underutilization, ")
        append("(4) performance bottlenecks. Provide specific, actionable recommendations.")
    }
    
    val response = generativeModel.generateContent(prompt)
    return DiagnosticResult(
        summary = extractSummary(response.text ?: ""),
        recommendations = extractRecommendations(response.text ?: ""),
        severity = determineSeverity(currentMetrics),
    )
}

Jetpack Compose Dashboard

The real-time dashboard uses Compose's collectAsState() with the Room Flow to update without polling:

@Composable
fun GpuDashboard(viewModel: GpuViewModel) {
    val metrics by viewModel.currentMetrics.collectAsState()
    val history by viewModel.metricsHistory.collectAsState()
    
    Column {
        MetricCard("GPU Utilization", "${metrics?.utilizationPercent?.roundToInt()}%") {
            LineChart(data = history.map { it.utilizationPercent ?: 0f })
        }
        MetricCard("Temperature", "${metrics?.temperatureCelsius?.roundToInt()}°C") {
            LineChart(
                data = history.map { it.temperatureCelsius ?: 0f },
                dangerThreshold = 85f,  // highlight thermal throttle zone
            )
        }
        // ...
        DiagnoseButton { viewModel.runDiagnostics() }
    }
}

Key Lessons

  1. Manufacturer fragmentation is real — GPU telemetry paths differ across Qualcomm, Mali, and PowerVR. Build a fallback chain.
  2. Background services need battery management — reduce sampling frequency when the app is backgrounded.
  3. Gemini prompts need metric context — raw numbers without context ("temperature: 87°C") get generic advice. Historical trends get specific advice.

GitHub: github.com/karthikrshet/GPU-Insight-AI