From 8e6ba4662f61ea4f479aa7e4a01d9eedbe067f21 Mon Sep 17 00:00:00 2001 From: cfig Date: Fri, 16 Jan 2026 20:02:53 +0800 Subject: [PATCH] laybox: add more commands - gki - boot timing analyzer --- lazybox/build.gradle.kts | 1 + lazybox/src/main/kotlin/cfig/lazybox/Apex.kt | 256 +++++ lazybox/src/main/kotlin/cfig/lazybox/App.kt | 72 +- .../kotlin/cfig/lazybox/BootTimeABCompare.kt | 147 +++ .../kotlin/cfig/lazybox/BootTimeStability.kt | 341 ++++++ lazybox/src/main/kotlin/cfig/lazybox/Gki.kt | 532 +++++---- .../cfig/lazybox/profiler/VideoAnalyzer.kt | 1017 +++++++++++++++++ lazybox/src/main/resources/boot_analysis.py | 664 +++++++++++ lazybox/src/main/resources/logback.xml | 2 +- 9 files changed, 2786 insertions(+), 246 deletions(-) create mode 100644 lazybox/src/main/kotlin/cfig/lazybox/Apex.kt create mode 100644 lazybox/src/main/kotlin/cfig/lazybox/BootTimeABCompare.kt create mode 100644 lazybox/src/main/kotlin/cfig/lazybox/BootTimeStability.kt create mode 100644 lazybox/src/main/kotlin/cfig/lazybox/profiler/VideoAnalyzer.kt create mode 100755 lazybox/src/main/resources/boot_analysis.py diff --git a/lazybox/build.gradle.kts b/lazybox/build.gradle.kts index 34eac1a..9d2ad89 100644 --- a/lazybox/build.gradle.kts +++ b/lazybox/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okio:okio:3.9.0") + implementation("org.bytedeco:javacv-platform:1.5.12") // Use the Kotlin JUnit 5 integration. testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") // Use the JUnit 5 integration. diff --git a/lazybox/src/main/kotlin/cfig/lazybox/Apex.kt b/lazybox/src/main/kotlin/cfig/lazybox/Apex.kt new file mode 100644 index 0000000..ca75e40 --- /dev/null +++ b/lazybox/src/main/kotlin/cfig/lazybox/Apex.kt @@ -0,0 +1,256 @@ +package cfig.lazybox + +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +private object Colors { + const val RED = "\u001B[0;31m" + const val GREEN = "\u001B[0;32m" + const val YELLOW = "\u001B[1;33m" + const val BLUE = "\u001B[0;34m" + const val CYAN = "\u001B[0;36m" + const val NC = "\u001B[0m" +} + +private fun printHeader(message: String) { + println("${Colors.BLUE}========================================${Colors.NC}") + println("${Colors.BLUE}$message${Colors.NC}") + println("${Colors.BLUE}========================================${Colors.NC}") +} + +private fun printInfo(message: String) { + println("${Colors.GREEN}[INFO]${Colors.NC} $message") +} + +private fun printWarn(message: String) { + println("${Colors.YELLOW}[WARN]${Colors.NC} $message") +} + +private fun printError(message: String) { + println("${Colors.RED}[ERROR]${Colors.NC} $message") +} + +private fun printSuccess(message: String) { + println("${Colors.GREEN}[OK]${Colors.NC} $message") +} + +private data class CommandResult(val output: List, val exitCode: Int) + +private fun runHostCommand(vararg command: String): CommandResult { + return try { + val process = ProcessBuilder(*command) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readLines() + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroy() + CommandResult(output, 124) + } else { + CommandResult(output, process.exitValue()) + } + } catch (e: Exception) { + CommandResult(emptyList(), 127) + } +} + +private class AdbHelper { + fun isAdbAvailable(): Boolean { + return runHostCommand("adb", "version").exitCode == 0 + } + + fun isConnected(): Boolean { + val devices = runHostCommand("adb", "devices").output + return devices.any { line -> + val trimmed = line.trim() + trimmed.isNotEmpty() && trimmed.endsWith("device") && !trimmed.startsWith("List") + } + } + + fun executeOnDevice(command: String): List { + return runHostCommand("adb", "shell", command).output + } + + fun findApexFiles(): List { + val apexFiles = mutableListOf() + apexFiles.addAll(executeOnDevice("find / -name '*.apex' 2>/dev/null")) + apexFiles.addAll(executeOnDevice("find / -name '*.capex' 2>/dev/null")) + return apexFiles.filter { it.isNotBlank() } + } + + fun getApexMounts(): List { + return executeOnDevice("mount | grep apex").filter { it.isNotBlank() } + } + + fun findPermissionController(): List { + return executeOnDevice("find / -name '*permission*.apex' 2>/dev/null").filter { it.isNotBlank() } + } +} + +class Apex { + private val adb = AdbHelper() + + private fun checkConnection() { + printHeader("Check ADB connection") + + if (!adb.isAdbAvailable()) { + printError("ADB is not installed or not in PATH") + exitProcess(1) + } + + if (!adb.isConnected()) { + printError("No connected devices found") + println("Please ensure:") + println(" 1. Device connected via USB") + println(" 2. USB debugging enabled") + println(" 3. ADB authorization granted") + exitProcess(1) + } + + printSuccess("ADB connection OK") + runHostCommand("adb", "devices").output.forEach { println(it) } + println() + } + + private fun findApexFiles() { + printHeader("Search APEX files") + + printInfo("Searching APEX files on device...") + val apexFiles = adb.findApexFiles() + + if (apexFiles.isEmpty()) { + printWarn("No APEX files found") + return + } + + printSuccess("Found ${apexFiles.size} APEX files") + println() + + printHeader("APEX file list") + apexFiles.sorted().forEachIndexed { index, file -> + println("${index + 1}. $file") + } + println() + + val apexCount = apexFiles.count { it.endsWith(".apex") } + val capexCount = apexFiles.count { it.endsWith(".capex") } + + printHeader("Statistics") + println("Total: ${apexFiles.size}") + println(" - .apex files: $apexCount") + println(" - .capex files: $capexCount") + println() + + printHeader("Group by location") + println() + + val byLocation = apexFiles.groupBy { file -> + when { + file.contains("/system/apex") -> "/system/apex" + file.startsWith("/apex") -> "/apex" + file.contains("/system/priv-app") -> "/system/priv-app" + file.contains("/data") -> "/data" + else -> "other" + } + } + + byLocation.forEach { (location, files) -> + println("${Colors.CYAN}$location:${Colors.NC}") + files.sorted().forEach { println(" $it") } + println() + } + + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) + val outputFile = File("apex_files_$timeStamp.txt") + outputFile.writeText(apexFiles.sorted().joinToString("\n")) + printInfo("Saved to: ${outputFile.absolutePath}") + println() + } + + private fun checkMounts() { + printHeader("Check APEX mounts") + + val mounts = adb.getApexMounts() + if (mounts.isEmpty()) { + printWarn("No mounted APEX found") + } else { + mounts.forEach { println(it) } + } + println() + } + + private fun checkPermissionController() { + printHeader("Check PermissionController") + + printInfo("Searching permission-related APEX...") + val permApex = adb.findPermissionController() + if (permApex.isEmpty()) { + printWarn("No permission-related APEX found") + } else { + permApex.forEach { println(it) } + } + println() + + printInfo("Searching permission XML files...") + val privappPerms = adb.executeOnDevice("find / -name 'privapp-permissions*.xml' 2>/dev/null") + .filter { it.isNotBlank() } + if (privappPerms.isEmpty()) { + printWarn("No permission XML files found") + } else { + privappPerms.forEach { println(it) } + } + println() + } + + private fun showHelp() { + println( + """ + Usage: lazybox apex [options] + + Options: + -h, --help Show help + -a, --all Run all checks + -f, --find Find APEX files (default) + -m, --mounts Check APEX mounts + -p, --perm Check PermissionController + + Examples: + lazybox apex + lazybox apex --all + lazybox apex --mounts + """.trimIndent() + ) + } + + fun run(args: Array) { + when { + args.isEmpty() || args[0] == "-f" || args[0] == "--find" -> { + checkConnection() + findApexFiles() + } + args[0] == "-h" || args[0] == "--help" -> { + showHelp() + } + args[0] == "-a" || args[0] == "--all" -> { + checkConnection() + findApexFiles() + checkMounts() + checkPermissionController() + } + args[0] == "-m" || args[0] == "--mounts" -> { + checkConnection() + checkMounts() + } + args[0] == "-p" || args[0] == "--perm" -> { + checkConnection() + checkPermissionController() + } + else -> { + println("Unknown option: ${args[0]}") + showHelp() + } + } + } +} diff --git a/lazybox/src/main/kotlin/cfig/lazybox/App.kt b/lazybox/src/main/kotlin/cfig/lazybox/App.kt index 1e3cd68..a65823e 100644 --- a/lazybox/src/main/kotlin/cfig/lazybox/App.kt +++ b/lazybox/src/main/kotlin/cfig/lazybox/App.kt @@ -1,5 +1,6 @@ package cfig.lazybox +import cfig.lazybox.profiler.VideoAnalyzer import cfig.lazybox.staging.AospCompiledb import cfig.lazybox.staging.DiffCI import cfig.lazybox.staging.Perfetto @@ -22,7 +23,7 @@ fun main(args: Array) { println("Usage: args: (Array) ...") println(" or: function [arguments]...") println("\nCurrently defined functions:") - println("\tcpuinfo gki sysinfo sysstat pidstat bootchart thermal_info compiledb") + println("\tcpuinfo gki sysinfo sysstat pidstat bootchart thermal_info compiledb analyze batch_analyze stat apex") println("\nCommand Usage:") println("bootchart: generate Android bootchart") println("gki : interactive GKI JSON downloader/parser, or process GKI modules from ") @@ -31,6 +32,12 @@ fun main(args: Array) { println("cpuinfo : get cpu info from /sys/devices/system/cpu/") println("sysinfo : get overall system info from Android") println("thermal_info : get thermal info from /sys/class/thermal/") + println("apex : find APEX files and mounts on Android device") + println("analyze [log|video] : analyze device boot performance from video recording and/or logs") + println(" default to analyze both video and logs") + println("batch_analyze : analyze every immediate subdirectory under ") + println("stat outlier : generate boot_time_stability_report.md for all runs under ") + println("stat compare [--a-name NAME] [--b-name NAME] : compare boot times between A and B") println("\nIncubating usage:") println("compiledb : generate compilation database for AOSP") println("dmainfo : parse /d/dma_buf/bufinfo") @@ -130,4 +137,67 @@ fun main(args: Array) { if (args[0] == "ina") { InaSensor().run() } + if (args[0] == "analyze") { + val subCommand = args.getOrNull(1) + if (subCommand == "log") { + // analyze log + VideoAnalyzer().analyzeLog(args.drop(2).toTypedArray()) + } else if (subCommand == "video") { + // analyze video + VideoAnalyzer().analyzeVideo(args.drop(2).toTypedArray()) + } else if (subCommand == "merge") { + VideoAnalyzer().mergeReports(args.drop(2).toTypedArray()) + } else { + // analyze + val analyzerArgs = args.drop(1).toTypedArray() + VideoAnalyzer().analyzeVideo(analyzerArgs) + VideoAnalyzer().analyzeLog(analyzerArgs) + VideoAnalyzer().mergeReports(analyzerArgs) + } + } + if (args[0] == "batch_analyze") { + if (args.size != 2) { + log.error("Usage: batch_analyze ") + return + } + val parentDir = File(args[1]) + if (!parentDir.exists() || !parentDir.isDirectory) { + log.error("Directory not found: ${parentDir.absolutePath}") + return + } + + val children = parentDir.listFiles()?.filter { it.isDirectory }?.sortedBy { it.name } ?: emptyList() + if (children.isEmpty()) { + log.warn("No subdirectories found under: ${parentDir.absolutePath}") + return + } + + for (child in children) { + log.info("batch_analyze: ${child.absolutePath}") + val analyzerArgs = arrayOf(child.absolutePath) + val analyzer = VideoAnalyzer() + val videoFile = File(child, "video.mp4") + val csvFile = File(child, "video.csv") + if (videoFile.exists() && csvFile.exists()) { + analyzer.analyzeVideo(analyzerArgs) + } else { + log.warn("Skip video analyze (video.mp4/video.csv missing): ${child.absolutePath}") + } + analyzer.analyzeLog(analyzerArgs) + analyzer.mergeReports(analyzerArgs) + } + } + if (args[0] == "stat") { + val subCommand = args.getOrNull(1) + if (subCommand == "outlier") { + BootTimeStability.run(args.drop(2).toTypedArray()) + } else if (subCommand == "compare") { + BootTimeABCompare.run(args.drop(2).toTypedArray()) + } else { + log.error("Usage: stat outlier | stat compare ") + } + } + if (args[0] == "apex") { + Apex().run(args.drop(1).toTypedArray()) + } } diff --git a/lazybox/src/main/kotlin/cfig/lazybox/BootTimeABCompare.kt b/lazybox/src/main/kotlin/cfig/lazybox/BootTimeABCompare.kt new file mode 100644 index 0000000..ad012d3 --- /dev/null +++ b/lazybox/src/main/kotlin/cfig/lazybox/BootTimeABCompare.kt @@ -0,0 +1,147 @@ +package cfig.lazybox + +import org.slf4j.LoggerFactory +import java.io.File + +object BootTimeABCompare { + private val log = LoggerFactory.getLogger(BootTimeABCompare::class.java) + private val nameSanitizer = Regex("[^A-Za-z_]") + + fun run(args: Array) { + if (args.isEmpty() || args.contains("--help") || args.contains("-h")) { + printUsage() + return + } + + val positionals = mutableListOf() + val milestones = mutableListOf() + var aName: String? = null + var bName: String? = null + var outDir: String? = null + var reportName: String? = null + + var i = 0 + while (i < args.size) { + val arg = args[i] + if (!arg.startsWith("--")) { + positionals.add(arg) + i += 1 + continue + } + + fun requireValue(): String { + if (i + 1 >= args.size) { + throw IllegalArgumentException("Missing value for $arg") + } + return args[i + 1] + } + + when (arg) { + "--a-name" -> { + aName = requireValue() + i += 2 + } + "--b-name" -> { + bName = requireValue() + i += 2 + } + "--out-dir" -> { + outDir = requireValue() + i += 2 + } + "--report-name" -> { + reportName = requireValue() + i += 2 + } + "--milestone" -> { + milestones.add(requireValue()) + i += 2 + } + else -> { + throw IllegalArgumentException("Unknown option: $arg") + } + } + } + + if (positionals.size < 2) { + log.error("Missing required directories for A and B.") + printUsage() + return + } + + val aDir = File(positionals[0]).absoluteFile + val bDir = File(positionals[1]).absoluteFile + if (!aDir.exists() || !aDir.isDirectory) { + log.error("Directory not found: ${aDir.absolutePath}") + return + } + if (!bDir.exists() || !bDir.isDirectory) { + log.error("Directory not found: ${bDir.absolutePath}") + return + } + + val script = findScript(File(".").absoluteFile) + if (script == null) { + log.error("boot_analysis.py not found under current or parent directories") + return + } + + val aLabel = aName ?: aDir.name + val bLabel = bName ?: bDir.name + val defaultOutDir = "${sanitizeName(aLabel)}_VS_${sanitizeName(bLabel)}" + + val cmd = mutableListOf( + "python3", + script.absolutePath, + "--a-name", + aLabel, + "--a-dir", + aDir.absolutePath, + "--b-name", + bLabel, + "--b-dir", + bDir.absolutePath, + ) + cmd.addAll(listOf("--out-dir", outDir ?: defaultOutDir)) + if (reportName != null) { + cmd.addAll(listOf("--report-name", reportName!!)) + } + if (milestones.isNotEmpty()) { + milestones.forEach { milestone -> + cmd.addAll(listOf("--milestone", milestone)) + } + } + + log.info("Running boot_analysis.py for A=$aLabel B=$bLabel") + val process = ProcessBuilder(cmd) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start() + + val exit = process.waitFor() + if (exit != 0) { + log.error("boot_analysis.py failed with exit code: $exit") + } + } + + private fun printUsage() { + println("Usage: stat AB [--a-name NAME] [--b-name NAME]") + println(" [--out-dir DIR] [--report-name NAME] [--milestone NAME]...") + } + + private fun findScript(startDir: File): File? { + var dir: File? = startDir + while (dir != null) { + val candidate = File(dir, "boot_analysis.py") + if (candidate.exists()) { + return candidate + } + dir = dir.parentFile + } + return null + } + + private fun sanitizeName(name: String): String { + return nameSanitizer.replace(name, "_") + } +} diff --git a/lazybox/src/main/kotlin/cfig/lazybox/BootTimeStability.kt b/lazybox/src/main/kotlin/cfig/lazybox/BootTimeStability.kt new file mode 100644 index 0000000..0bf0a78 --- /dev/null +++ b/lazybox/src/main/kotlin/cfig/lazybox/BootTimeStability.kt @@ -0,0 +1,341 @@ +package cfig.lazybox + +import java.io.File +import kotlin.math.sqrt +import kotlin.system.exitProcess + +data class BootRun(val name: String, val stages: Map) + +data class StageStats( + val mean: Double, + val std: Double, + val min: Double, + val max: Double, + val jitter: Double, +) + +data class Outlier(val run: String, val stage: String, val value: Double, val median: Double, val threshold: Double) + +object BootTimeStability { + private val requiredStages = listOf( + "Kernel Start", + "Boot Progress Start", + "AMS Ready", + "Boot Completed", + "Launcher Start", + "Launcher loaded", + ) + + private fun parseSeconds(token: String): Double? { + val t = token.trim().removeSuffix("s").trim() + return t.toDoubleOrNull() + } + + private fun parseMergedBootReport(file: File): Map { + val found = mutableMapOf() + + file.forEachLine { line -> + val trimmed = line.trim() + if (!trimmed.startsWith("|")) return@forEachLine + if (trimmed.startsWith("|---") || trimmed.contains("TimeStamp") && trimmed.contains("Delta(A)")) return@forEachLine + + val cols = trimmed + .split("|") + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (cols.size < 6) return@forEachLine + + val logEvent = cols[1] + val visualEvent = cols[2] + val deltaA = parseSeconds(cols[3]) ?: return@forEachLine + + val logEventLc = logEvent.lowercase() + val visualEventLc = visualEvent.lowercase() + + fun maybeSet(stage: String, matched: Boolean) { + if (matched && !found.containsKey(stage)) { + found[stage] = deltaA + } + } + + maybeSet("Kernel Start", logEventLc == "kernel start" || visualEventLc == "kernel start") + maybeSet("Boot Progress Start", logEventLc == "boot progress start" || visualEventLc == "boot progress start") + maybeSet("AMS Ready", logEventLc == "ams ready" || visualEventLc == "ams ready") + maybeSet("Boot Completed", logEventLc == "boot completed" || visualEventLc == "boot completed") + maybeSet("Launcher Start", logEventLc == "launcher start" || visualEventLc == "launcher start") + maybeSet("Launcher loaded", logEventLc == "launcher loaded" || visualEventLc == "launcher loaded") + } + + return found + } + + private fun median(values: List): Double { + if (values.isEmpty()) return 0.0 + val sorted = values.sorted() + val n = sorted.size + return if (n % 2 == 1) { + sorted[n / 2] + } else { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } + } + + private fun mean(values: List): Double = if (values.isEmpty()) 0.0 else values.sum() / values.size + + private fun sampleStd(values: List): Double { + if (values.size <= 1) return 0.0 + val m = mean(values) + val variance = values.sumOf { (it - m) * (it - m) } / (values.size - 1) + return sqrt(variance) + } + + private fun statsForStage(runs: List, stage: String): StageStats { + val values = runs.mapNotNull { it.stages[stage] } + if (values.isEmpty()) return StageStats(0.0, 0.0, 0.0, 0.0, 0.0) + val minV = values.minOrNull() ?: 0.0 + val maxV = values.maxOrNull() ?: 0.0 + return StageStats( + mean = mean(values), + std = sampleStd(values), + min = minV, + max = maxV, + jitter = maxV - minV, + ) + } + + private fun format3(v: Double): String = String.format("%.3f", v) + + private fun markdownTable(runs: List): String { + val header = buildString { + append("| Run | ") + append(requiredStages.joinToString(" | ")) + append(" |\n") + append("|:--|") + append(requiredStages.joinToString("|") { "--:" }) + append("|\n") + } + + val rows = runs.joinToString("\n") { run -> + val cols = requiredStages.joinToString(" | ") { stage -> + val v = run.stages[stage] + if (v == null) "" else format3(v) + } + "| ${run.name} | $cols |" + } + + return header + rows + "\n" + } + + private fun markdownStatsTable(stats: Map): String { + fun s(stage: String) = stats[stage] ?: StageStats(0.0, 0.0, 0.0, 0.0, 0.0) + + val header = "| Metric | ${requiredStages.joinToString(" | ")} |" + val sep = "|----------------|" + requiredStages.joinToString("|") { "------:" } + "|" + + val lineMean = "| **Mean** | " + requiredStages.joinToString(" | ") { stage -> format3(s(stage).mean) } + " |" + val lineStd = "| **Std Dev** | " + requiredStages.joinToString(" | ") { stage -> format3(s(stage).std) } + " |" + val lineJitter = "| **Jitter** | " + requiredStages.joinToString(" | ") { stage -> format3(s(stage).jitter) } + " |" + + return listOf(header, sep, lineMean, lineStd, lineJitter).joinToString("\n") + "\n" + } + + fun run(args: Array) { + if (args.isEmpty()) { + System.err.println("Usage: lazybox stat outlier ") + exitProcess(1) + } + + val inputDir = File(args[0]) + if (!inputDir.exists() || !inputDir.isDirectory) { + System.err.println("Error: '${inputDir.absolutePath}' is not a directory") + exitProcess(1) + } + + val runDirs = inputDir.listFiles()?.filter { it.isDirectory }?.sortedBy { it.name } ?: emptyList() + + println("[boot-stability] inputDir=${inputDir.absolutePath}") + println("[boot-stability] found ${runDirs.size} first-level subdirectories") + + val parsedRuns = mutableListOf() + val skippedRuns = mutableListOf() + + for (dir in runDirs) { + val reportFile = File(dir, "merged_boot_report.md") + if (!reportFile.exists() || !reportFile.isFile) { + skippedRuns.add("${dir.name} (missing merged_boot_report.md)") + println("[boot-stability] SKIP ${dir.name}: merged_boot_report.md not found") + continue + } + + val stageMap = parseMergedBootReport(reportFile) + + val foundStages = requiredStages.filter { stageMap.containsKey(it) } + val missingStages = requiredStages.filter { !stageMap.containsKey(it) } + println( + "[boot-stability] RUN ${dir.name}: found=${foundStages.joinToString(", ")}" + + if (missingStages.isEmpty()) "" else "; missing=${missingStages.joinToString(", ")}" + ) + parsedRuns.add(BootRun(dir.name, stageMap)) + } + + if (parsedRuns.isEmpty()) { + System.err.println("No valid runs found under: ${inputDir.absolutePath}") + exitProcess(2) + } + + val originalRuns = parsedRuns.toList() + + println("[boot-stability] valid runs=${originalRuns.size}, skipped=${skippedRuns.size}") + + val mediansByStage = requiredStages.associateWith { stage -> + median(originalRuns.mapNotNull { it.stages[stage] }) + } + + for (stage in requiredStages) { + val m = mediansByStage[stage] ?: 0.0 + if (m > 0.0) { + println("[boot-stability] median[$stage]=${format3(m)} threshold=${format3(m * 2.0)}") + } else { + println("[boot-stability] median[$stage]=N/A (no samples)") + } + } + + val outliers = mutableListOf() + val outlierRunNames = mutableSetOf() + + for (stage in requiredStages) { + val medianV = mediansByStage[stage] ?: 0.0 + val threshold = medianV * 2.0 + for (run in originalRuns) { + val v = run.stages[stage] ?: continue + if (medianV > 0.0 && v > threshold) { + outliers.add(Outlier(run.name, stage, v, medianV, threshold)) + outlierRunNames.add(run.name) + } + } + } + + if (outliers.isEmpty()) { + println("[boot-stability] outliers: none") + } else { + println("[boot-stability] outliers: ${outliers.size}") + outliers.forEach { o -> + println( + "[boot-stability] outlier run=${o.run} stage=${o.stage} " + + "value=${format3(o.value)} threshold=${format3(o.threshold)}" + ) + } + println("[boot-stability] outlier runs=${outlierRunNames.sorted().joinToString(", ")}") + } + + val cleanedRuns = originalRuns.filter { it.name !in outlierRunNames } + + println("[boot-stability] cleaned runs=${cleanedRuns.size}") + + if (outlierRunNames.isNotEmpty()) { + println("[boot-stability] removed runs=${outlierRunNames.sorted().joinToString(", ")}") + } + + val originalStats = requiredStages.associateWith { stage -> statsForStage(originalRuns, stage) } + val cleanedStats = requiredStages.associateWith { stage -> statsForStage(cleanedRuns, stage) } + + println("[boot-stability] original stats summary:") + for (stage in requiredStages) { + val n = originalRuns.mapNotNull { it.stages[stage] }.size + val st = originalStats.getValue(stage) + if (n == 0) { + println("[boot-stability] - $stage: n=0") + } else { + println("[boot-stability] - $stage: n=$n mean=${format3(st.mean)} std=${format3(st.std)} jitter=${format3(st.jitter)}") + } + } + + println("[boot-stability] cleaned stats summary:") + for (stage in requiredStages) { + val n = cleanedRuns.mapNotNull { it.stages[stage] }.size + val st = cleanedStats.getValue(stage) + if (n == 0) { + println("[boot-stability] - $stage: n=0") + } else { + println("[boot-stability] - $stage: n=$n mean=${format3(st.mean)} std=${format3(st.std)} jitter=${format3(st.jitter)}") + } + } + + val outlierListStr = if (outliers.isEmpty()) { + "No outliers detected." + } else { + outliers.joinToString("\n") { o -> + "- Run `${o.run}` in stage **${o.stage}**: Value `${format3(o.value)}s` > Threshold `${format3(o.threshold)}s` (Median was ${format3(o.median)}s)" + } + } + + val outlierImpactSummary = when { + outliers.isEmpty() -> "- **Outlier Impact**: No outliers were detected, indicating consistent boot times across all runs." + outliers.size == 1 -> { + val o = outliers[0] + "- **Outlier Impact**: The run `${o.run}` was identified as an outlier. The most significant deviation was in the **${o.stage}** stage, " + + "with a time of `${format3(o.value)}s`, which is more than double the median of the stable runs (`${format3(o.median)}s`)." + } + else -> "- **Outlier Impact**: ${outliers.size} outliers were detected across various runs and stages. " + + "Refer to the 'Data Cleaning: Outlier Detection' section above for detailed information on each outlier. " + + "These outliers represent significant deviations from the median boot times in their respective stages." + } + + val outlierRunForConclusion = outlierRunNames.firstOrNull() ?: "N/A" + + val skippedSection = if (skippedRuns.isEmpty()) { + "" + } else { + """ +## Skipped Runs + +The following runs were skipped because they were missing required inputs: + +${skippedRuns.joinToString("\n") { "- $it" }} + +""".trimIndent() + "\n" + } + + val report = """ +# Boot Time Stability Analysis Report (kts) + +This report analyzes the boot time of the device based on ${originalRuns.size} runs, with data cleaning applied to remove outliers. +A more robust outlier detection method is used: a data point is considered an outlier if its value is greater than 200% of the **median** for that specific boot stage (`value > 2 * median`). + +## 1. Original Data Summary + +### Boot Times (in seconds) +${markdownTable(originalRuns)} + +### Statistical Analysis (Original Data, All Runs) +${markdownStatsTable(originalStats)} + +## 2. Data Cleaning: Outlier Detection + +The following outliers were detected and removed based on the median rule: + +$outlierListStr + +## 3. Cleaned Data Summary + +### Statistical Analysis (Cleaned Data, ${cleanedRuns.size} Stable Runs) +_This analysis excludes the entire run(s) identified as containing outliers._ + +${markdownStatsTable(cleanedStats)} + +## 4. Observations + +$outlierImpactSummary +- **Stability (Cleaned Data)**: After removing the outlier run, the data for the remaining ${cleanedRuns.size} runs is more stable. For example, the standard deviation for 'Kernel Start' dropped from `${format3(originalStats.getValue("Kernel Start").std)}s` to `${format3(cleanedStats.getValue("Kernel Start").std)}s`. +- **Conclusion**: The cleaned data provides an accurate representation of typical boot performance. The outlier run from `$outlierRunForConclusion` should be investigated separately to understand the cause of its significant delay. + +$skippedSection +""".trimIndent() + "\n" + + val outFile = File(inputDir, "boot_time_stability_report.md") + outFile.writeText(report) + + println("Report generated successfully at ${outFile.absolutePath}") + } +} diff --git a/lazybox/src/main/kotlin/cfig/lazybox/Gki.kt b/lazybox/src/main/kotlin/cfig/lazybox/Gki.kt index a64d626..499f5a1 100644 --- a/lazybox/src/main/kotlin/cfig/lazybox/Gki.kt +++ b/lazybox/src/main/kotlin/cfig/lazybox/Gki.kt @@ -11,312 +11,356 @@ import java.security.MessageDigest import kotlin.io.path.createTempDirectory import kotlin.system.exitProcess -@JsonIgnoreProperties(ignoreUnknown = true) -data class GkiBuild( - val name: String, - val branches: List -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class Branch( - val name: String, - val kernel_version: String, - val releases: List -) - -@JsonIgnoreProperties(ignoreUnknown = true) -data class Release( - val tag: String, - val date: String, - val sha1: String, - val kernel_bid: String -) - -fun printHelp() { - println(""" - Usage: - kotlinc -script gki.kts # Run interactive GKI JSON downloader/parser - kotlinc -script gki.kts # Process GKI modules from input directory - kotlinc -script gki.kts -h # Show this help message - """.trimIndent()) -} +class Gki { + private val objectMapper = jacksonObjectMapper() + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + @JsonIgnoreProperties(ignoreUnknown = true) + data class GkiBuild( + val name: String, + val branches: List + ) -fun runGkiJsonLogic() { - println("Running GKI JSON logic...") - val todoFile = File("to-do.txt") + @JsonIgnoreProperties(ignoreUnknown = true) + data class Branch( + val name: String, + val kernel_version: String, + val releases: List + ) - val branchesMap = listOf( - "android14-5_15" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android14-5_15-release-builds.json", - "android14-6_1" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android14-6_1-release-builds.json", - "android15-6_6" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android15-6_6-release-builds.json", - "android16-6_12" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android16-6_12-release-builds.json" + @JsonIgnoreProperties(ignoreUnknown = true) + data class Release( + val tag: String, + val date: String, + val sha1: String, + val kernel_bid: String ) - println("Select a GKI branch:") - branchesMap.forEachIndexed { index, (name, _) -> - println("${index + 1}. $name") + private fun printHelp() { + val helpMessage = """ + |Usage: + | ./gradlew :lazybox:runGki # Run interactive GKI JSON downloader/parser + | ./gradlew :lazybox:runGki -- # Process GKI modules from input directory + | ./gradlew :lazybox:runGki -- -h # Show this help message + """.trimMargin() + println(helpMessage) } - print("Enter choice (1-${branchesMap.size}) [Default: 1]: ") - val input = readlnOrNull() - val choice = if (input.isNullOrBlank()) 1 else input.toIntOrNull() + private fun runGkiJsonLogic() { + println("Running GKI JSON logic...") + val todoFile = File("to-do.txt") - if (choice == null || choice !in 1..branchesMap.size) { - println("Invalid choice.") - return - } + val branchesMap = listOf( + "android14-5_15" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android14-5_15-release-builds.json", + "android14-6_1" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android14-6_1-release-builds.json", + "android15-6_6" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android15-6_6-release-builds.json", + "android16-6_12" to "https://source.android.com/static/docs/core/architecture/kernel/gki-android16-6_12-release-builds.json" + ) - val (branchName, url) = branchesMap[choice - 1] - val jsonFileName = "gki-$branchName-release-builds.json" - val file = File(jsonFileName) + println("Select a GKI branch:") + branchesMap.forEachIndexed { index, (name, _) -> + println("${index + 1}. $name") + } - if (!file.exists()) { - println("File $jsonFileName not found, downloading from $url.") - try { - val client = OkHttpClient() - val request = Request.Builder().url(url).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw Exception("Request failed: $response") - file.writeText(response.body!!.string()) + print("Enter choice (1-${branchesMap.size}) [Default: 1]: ") + val input = readlnOrNull() + val choice = if (input.isNullOrBlank()) 1 else input.toIntOrNull() + + if (choice == null || choice !in 1..branchesMap.size) { + println("Invalid choice.") + return + } + + val (branchName, url) = branchesMap[choice - 1] + val jsonFileName = "gki-$branchName-release-builds.json" + val file = File(jsonFileName) + + if (!file.exists()) { + println("File $jsonFileName not found, downloading from $url.") + try { + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Exception("Request failed: $response") + file.writeText(response.body!!.string()) + } + println("Download complete.") + } catch (e: Exception) { + println("Download failed: ${e.message}") + return } - println("Download complete.") - } catch (e: Exception) { - println("Download failed: ${e.message}") + } + + val content = file.readText() + val jsonString: String + val jsonStartMarker = "" + val startIndex = content.indexOf(jsonStartMarker) + + if (startIndex != -1) { + val fromStart = content.substring(startIndex + jsonStartMarker.length) + jsonString = fromStart.substringBefore("").trim() + } else if (content.trim().startsWith("{")) { + jsonString = content.trim() + } else { + println("Error: JSON data not found in file.") return } - } - val content = file.readText() - val jsonString: String - val jsonStartMarker = "" - val startIndex = content.indexOf(jsonStartMarker) - - if (startIndex != -1) { - val fromStart = content.substring(startIndex + jsonStartMarker.length) - jsonString = fromStart.substringBefore("").trim() - } else if (content.trim().startsWith("{")) { - jsonString = content.trim() - } else { - println("Error: JSON data not found in file.") - return - } + val gkiBuild: GkiBuild + try { + gkiBuild = objectMapper.readValue(jsonString) + } catch (e: Exception) { + println("JSON parsing failed: ${e.message}") + return + } - val mapper = jacksonObjectMapper() - val gkiBuild: GkiBuild - try { - gkiBuild = mapper.readValue(jsonString) - } catch (e: Exception) { - println("JSON parsing failed: ${e.message}") - return - } + val expectedNameFromFile = jsonFileName.substringAfter("gki-").substringBefore("-release-builds.json") + if (gkiBuild.name.replace('_', '.') != expectedNameFromFile.replace('_', '.')) { + println("Error: JSON name ('${gkiBuild.name}') does not match expected name from filename ('$expectedNameFromFile').") + return + } - val expectedNameFromFile = jsonFileName.substringAfter("gki-").substringBefore("-release-builds.json") - if (gkiBuild.name.replace('_', '.') != expectedNameFromFile.replace('_', '.')) { - println("Error: JSON name ('${gkiBuild.name}') does not match expected name from filename ('$expectedNameFromFile').") - return - } + println("\nFound ${gkiBuild.branches.size} branches. Please select one:") + gkiBuild.branches.forEachIndexed { index, branch -> + println("${index + 1}. ${branch.name}") + } - println("\nFound ${gkiBuild.branches.size} branches. Please select one:") - gkiBuild.branches.forEachIndexed { index, branch -> - println("${index + 1}. ${branch.name}") - } + print("Enter choice (1-${gkiBuild.branches.size}): ") + val branchChoice = readlnOrNull()?.toIntOrNull() + if (branchChoice == null || branchChoice !in 1..gkiBuild.branches.size) { + println("Invalid choice.") + return + } - print("Enter choice (1-${gkiBuild.branches.size}): ") - val branchChoice = readlnOrNull()?.toIntOrNull() - if (branchChoice == null || branchChoice !in 1..gkiBuild.branches.size) { - println("Invalid choice.") - return - } + val selectedBranch = gkiBuild.branches[branchChoice - 1] + println("\n--- Branch Info ---\n${objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedBranch)}") - val selectedBranch = gkiBuild.branches[branchChoice - 1] - println("\n--- Branch Info ---\n${mapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedBranch)}") + if (selectedBranch.releases.isEmpty()) { + println("\nNo releases available for this branch.") + return + } - if (selectedBranch.releases.isEmpty()) { - println("\nNo releases available for this branch.") - return - } + println("\nFound ${selectedBranch.releases.size} releases. Please select one:") + selectedBranch.releases.forEachIndexed { index, release -> + println("${index + 1}. ${release.tag}") + } - println("\nFound ${selectedBranch.releases.size} releases. Please select one:") - selectedBranch.releases.forEachIndexed { index, release -> - println("${index + 1}. ${release.tag}") - } + print("Enter choice (1-${selectedBranch.releases.size}): ") + val releaseChoice = readlnOrNull()?.toIntOrNull() + if (releaseChoice == null || releaseChoice !in 1..selectedBranch.releases.size) { + println("Invalid choice.") + return + } - print("Enter choice (1-${selectedBranch.releases.size}): ") - val releaseChoice = readlnOrNull()?.toIntOrNull() - if (releaseChoice == null || releaseChoice !in 1..selectedBranch.releases.size) { - println("Invalid choice.") - return + val selectedRelease = selectedBranch.releases[releaseChoice - 1] + val tempVal = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedRelease) + println("\n--- Release Info ---\n$tempVal") + + // --- URL Generation Logic --- + println("\nGenerating URL list...") + + val kernelBid = selectedRelease.kernel_bid + + val urlsToGenerate = listOf( + // Debug + "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/boot.img", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/system_dlkm_staging_archive.tar.gz", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/vmlinux.symvers", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/view/BUILD_INFO", + // Release + "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/signed/certified-boot-img-$kernelBid.tar.gz", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/system_dlkm_staging_archive.tar.gz", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/vmlinux.symvers", + "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/view/BUILD_INFO" + ) + + todoFile.writeText(urlsToGenerate.joinToString("\n")) + println("URL list successfully written to ${todoFile.path}") + println("\nScript execution complete.") + File("bid_$kernelBid").mkdir() } - val selectedRelease = selectedBranch.releases[releaseChoice - 1] - val tempVal = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(selectedRelease) - println("\n--- Release Info ---\n$tempVal") - - // --- URL Generation Logic --- - println("\nGenerating URL list...") - - val kernelBid = selectedRelease.kernel_bid - - val urlsToGenerate = listOf( - // Debug - "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/boot.img", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/system_dlkm_staging_archive.tar.gz", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/vmlinux.symvers", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_debug_aarch64/latest/view/BUILD_INFO", - // Release - "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/signed/certified-boot-img-$kernelBid.tar.gz", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/system_dlkm_staging_archive.tar.gz", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/vmlinux.symvers", - "https://ci.android.com/builds/submitted/$kernelBid/kernel_aarch64/latest/view/BUILD_INFO" - ) + // --- Logic from gki2.kts --- + private fun runGki2Logic(inputPath: String) { + println("Running GKI Modules Processing logic...") + val inputDir = File(inputPath) - todoFile.writeText(urlsToGenerate.joinToString("\n")) - println("URL list successfully written to ${todoFile.path}") - println("\nScript execution complete.") - File("bid_$kernelBid").mkdir() -} + if (!inputDir.exists() || !inputDir.isDirectory) { + System.err.println("Error: Input directory '$inputPath' does not exist or is not a directory.") + exitProcess(1) + } -// --- Logic from gki2.kts --- -fun runGki2Logic(inputPath: String) { - println("Running GKI Modules Processing logic...") - val inputDir = File(inputPath) + println("Input directory: ${inputDir.absolutePath}") - if (!inputDir.exists() || !inputDir.isDirectory) { - System.err.println("Error: Input directory '$inputPath' does not exist or is not a directory.") - exitProcess(1) - } + // --- Boot Image Handling --- + val certifiedBootImg = inputDir.walk().firstOrNull { it.isFile && it.name.startsWith("certified-boot-img-") && it.name.endsWith(".tar.gz") } + val bootImg = inputDir.resolve("boot.img") + val outputDirName = when { + certifiedBootImg != null -> "gki_modules" + bootImg.exists() -> "gki_modules_debug" + else -> { + System.err.println("Error: Neither certified-boot-img-*.tar.gz nor boot.img found in '${inputDir.absolutePath}'") + exitProcess(1) + } + } - println("Input directory: ${inputDir.absolutePath}") + val outputDir = File(outputDirName) + val inputDirPath = inputDir.canonicalFile + val outputDirPath = outputDir.canonicalFile + if (inputDirPath == outputDirPath) { + System.err.println("Error: Output directory '${outputDirPath.absolutePath}' must not be the same as input directory.") + exitProcess(1) + } + println("Cleaning up and creating output directory: ${outputDir.absolutePath}") + if (outputDir.exists()) { + outputDir.deleteRecursively() + } + outputDir.mkdir() - val outputDir = File("gki_modules") - println("Cleaning up and creating output directory: ${outputDir.absolutePath}") - if (outputDir.exists()) { - outputDir.deleteRecursively() - } - outputDir.mkdir() + // --- Kernel Modules Handling --- + val systemDlkm = inputDir.resolve("system_dlkm_staging_archive.tar.gz") - // --- Boot Image Handling --- - val certifiedBootImg = inputDir.walk().firstOrNull { it.isFile && it.name.startsWith("certified-boot-img-") && it.name.endsWith(".tar.gz") } + if (!systemDlkm.exists()) { + System.err.println("Error: system_dlkm_staging_archive.tar.gz not found in '${inputDir.absolutePath}'") + exitProcess(1) + } - if (certifiedBootImg != null) { - println("Found certified boot image: ${certifiedBootImg.name}") - val tempBootDir = createTempDirectory("boot_img_extraction").toFile() - println("Extracting ${certifiedBootImg.name} to ${tempBootDir.absolutePath}...") - "tar -xzf ${certifiedBootImg.absolutePath} -C ${tempBootDir.absolutePath}".runCommand() + println("Found kernel modules archive: ${systemDlkm.name}") + val tempModulesDir = createTempDirectory("modules_extraction").toFile() + println("Extracting ${systemDlkm.name} to ${tempModulesDir.absolutePath}...") + "tar -xzf ${systemDlkm.absolutePath} -C ${tempModulesDir.absolutePath}".runCommand() - val bootImgInTar = tempBootDir.resolve("boot.img") - if (!bootImgInTar.exists()) { - System.err.println("Error: boot.img not found inside ${certifiedBootImg.name}") - tempBootDir.deleteRecursively() + val modulesDir = tempModulesDir.resolve("flatten/lib/modules") + if (!modulesDir.exists() || !modulesDir.isDirectory) { + System.err.println("Error: 'flatten/lib/modules' directory not found inside the extracted archive.") + tempModulesDir.deleteRecursively() exitProcess(1) } - val newBootImg = outputDir.resolve("boot-5.15.img") - println("Copying and renaming boot.img to ${newBootImg.absolutePath}") - bootImgInTar.copyTo(newBootImg, overwrite = true) - tempBootDir.deleteRecursively() - - } else { - val bootImg = inputDir.resolve("boot.img") - if (!bootImg.exists()) { - System.err.println("Error: Neither certified-boot-img-*.tar.gz nor boot.img found in '${inputDir.absolutePath}'") + val sampleKo = modulesDir.walk().firstOrNull { it.isFile && it.extension == "ko" } + if (sampleKo == null) { + System.err.println("Error: No .ko files found inside '${modulesDir.absolutePath}'") + tempModulesDir.deleteRecursively() exitProcess(1) } - println("Found boot.img directly in input directory.") - val newBootImg = outputDir.resolve("boot-5.15.img") - println("Copying boot.img to ${newBootImg.absolutePath}") - bootImg.copyTo(newBootImg, overwrite = true) - } + val vermagic = commandOutput("modinfo", "-F", "vermagic", sampleKo.absolutePath).trim() + val shortKernelVersion = Regex("""^(\d+\.\d+)""").find(vermagic)?.groupValues?.get(1) + ?: run { + System.err.println("Error: Failed to parse kernel version from vermagic: '$vermagic'") + tempModulesDir.deleteRecursively() + exitProcess(1) + } - // --- Kernel Modules Handling --- - val systemDlkm = inputDir.resolve("system_dlkm_staging_archive.tar.gz") + val bootSuffix = if (outputDirName == "gki_modules_debug") "-debug" else "" - if (!systemDlkm.exists()) { - System.err.println("Error: system_dlkm_staging_archive.tar.gz not found in '${inputDir.absolutePath}'") - exitProcess(1) - } + if (certifiedBootImg != null) { + println("Found certified boot image: ${certifiedBootImg.name}") + val tempBootDir = createTempDirectory("boot_img_extraction").toFile() + println("Extracting ${certifiedBootImg.name} to ${tempBootDir.absolutePath}...") + "tar -xzf ${certifiedBootImg.absolutePath} -C ${tempBootDir.absolutePath}".runCommand() - println("Found kernel modules archive: ${systemDlkm.name}") - val tempModulesDir = createTempDirectory("modules_extraction").toFile() - println("Extracting ${systemDlkm.name} to ${tempModulesDir.absolutePath}...") - "tar -xzf ${systemDlkm.absolutePath} -C ${tempModulesDir.absolutePath}".runCommand() + val bootImgInTar = tempBootDir.resolve("boot.img") + if (!bootImgInTar.exists()) { + System.err.println("Error: boot.img not found inside ${certifiedBootImg.name}") + tempBootDir.deleteRecursively() + tempModulesDir.deleteRecursively() + exitProcess(1) + } - val modulesDir = tempModulesDir.resolve("flatten/lib/modules") - if (!modulesDir.exists() || !modulesDir.isDirectory) { - System.err.println("Error: 'flatten/lib/modules' directory not found inside the extracted archive.") - tempModulesDir.deleteRecursively() - exitProcess(1) - } + val newBootImg = outputDir.resolve("boot-$shortKernelVersion$bootSuffix.img") + println("Copying and renaming boot.img to ${newBootImg.absolutePath}") + bootImgInTar.copyTo(newBootImg, overwrite = true) + tempBootDir.deleteRecursively() - println("Copying .ko files to ${outputDir.absolutePath}...") - modulesDir.walk().forEach { - if (it.isFile && it.extension == "ko") { - it.copyTo(outputDir.resolve(it.name), overwrite = true) + } else { + println("Found boot.img directly in input directory.") + val newBootImg = outputDir.resolve("boot-$shortKernelVersion$bootSuffix.img") + println("Copying boot.img to ${newBootImg.absolutePath}") + bootImg.copyTo(newBootImg, overwrite = true) } - } - tempModulesDir.deleteRecursively() - println("Finished copying kernel modules.") - - - // --- MD5 Sum Generation --- - println("Generating md5sum.txt...") - val md5sumFile = outputDir.resolve("md5sum.txt") - val md5sums = mutableListOf() - outputDir.walk().sortedBy { it.name }.forEach { - if (it.isFile && it.name != "md5sum.txt") { - val md5 = it.md5() - md5sums.add("$md5 ${it.name}") - println(" - Calculated MD5 for ${it.name}") + + println("Copying .ko files to ${outputDir.absolutePath}...") + modulesDir.walk().forEach { + if (it.isFile && it.extension == "ko") { + it.copyTo(outputDir.resolve(it.name), overwrite = true) + } + } + tempModulesDir.deleteRecursively() + println("Finished copying kernel modules.") + + + // --- MD5 Sum Generation --- + println("Generating md5sum.txt...") + val md5sumFile = outputDir.resolve("md5sum.txt") + val md5sums = mutableListOf() + outputDir.walk().sortedBy { it.name }.forEach { + if (it.isFile && it.name != "md5sum.txt") { + val md5 = it.md5() + md5sums.add("$md5 ${it.name}") + println(" - Calculated MD5 for ${it.name}") + } } + md5sumFile.writeText(md5sums.joinToString("\n")) + println("md5sum.txt created successfully.") + + println("\nDone! All files are in '${outputDir.absolutePath}'.") } - md5sumFile.writeText(md5sums.joinToString("\n")) - println("md5sum.txt created successfully.") - println("\nDone! All files are in '${outputDir.absolutePath}'.") -} + // --- Helpers --- + private fun String.runCommand(workingDir: File = File(".")) { + val process = ProcessBuilder(*split(" ").toTypedArray()) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start() + if (process.waitFor() != 0) { + throw RuntimeException("Failed to run command: $this") + } + } -// --- Helpers --- -fun String.runCommand(workingDir: File = File(".")) { - val process = ProcessBuilder(*split(" ").toTypedArray()) - .directory(workingDir) - .redirectOutput(ProcessBuilder.Redirect.INHERIT) - .redirectError(ProcessBuilder.Redirect.INHERIT) - .start() - if (process.waitFor() != 0) { - throw RuntimeException("Failed to run command: $this") + private fun commandOutput(vararg args: String, workingDir: File = File(".")): String { + val process = ProcessBuilder(*args) + .directory(workingDir) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (process.waitFor() != 0) { + throw RuntimeException("Failed to run command: ${args.joinToString(" ")}") + } + return output } -} -fun File.md5(): String { - val md = MessageDigest.getInstance("MD5") - this.inputStream().use { - val buffer = ByteArray(8192) - var bytesRead = it.read(buffer) - while (bytesRead != -1) { - md.update(buffer, 0, bytesRead) - bytesRead = it.read(buffer) + private fun File.md5(): String { + val md = MessageDigest.getInstance("MD5") + this.inputStream().use { + val buffer = ByteArray(8192) + var bytesRead = it.read(buffer) + while (bytesRead != -1) { + md.update(buffer, 0, bytesRead) + bytesRead = it.read(buffer) + } } + return md.digest().joinToString("") { "%02x".format(it) } } - return md.digest().joinToString("") { "%02x".format(it) } -} -class Gki { companion object { fun run(args: Array) { - // --- Main Entry Point --- + val gki = Gki() if (args.isNotEmpty() && (args[0] == "-h" || args[0] == "--help")) { - printHelp() + gki.printHelp() exitProcess(0) } if (args.isEmpty()) { - runGkiJsonLogic() + gki.runGkiJsonLogic() } else if (args.size == 1) { - runGki2Logic(args[0]) + gki.runGki2Logic(args[0]) } else { println("Invalid arguments.") - printHelp() + gki.printHelp() exitProcess(1) } } diff --git a/lazybox/src/main/kotlin/cfig/lazybox/profiler/VideoAnalyzer.kt b/lazybox/src/main/kotlin/cfig/lazybox/profiler/VideoAnalyzer.kt new file mode 100644 index 0000000..4c5fee6 --- /dev/null +++ b/lazybox/src/main/kotlin/cfig/lazybox/profiler/VideoAnalyzer.kt @@ -0,0 +1,1017 @@ +package cfig.lazybox.profiler + +import org.bytedeco.javacv.FFmpegFrameGrabber +import org.bytedeco.javacv.OpenCVFrameConverter +import org.bytedeco.opencv.global.opencv_core.* +import org.bytedeco.opencv.global.opencv_imgcodecs.* +import org.bytedeco.opencv.global.opencv_imgproc.* +import org.bytedeco.opencv.opencv_core.Mat +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.system.exitProcess +import org.bytedeco.javacv.FFmpegLogCallback +import org.slf4j.LoggerFactory +import java.util.Properties + +class VideoAnalyzer { + // === 配置区 === + val RES_DIR = "res" + val DESC_FILE = "desc.txt" + val LOG_PATTERN_FILE = "res/log_patterns.txt" + + // === 数据类定义 === + data class StageVariation( + val file: String, + val tpl: Mat, + val threshold: Double = 0.8, + val minBrightness: Int = 0, + val isBrightnessCheck: Boolean = false, + val maxBrightness: Int = 255 + ) + + data class LogicalStage( + val name: String, + val isOptional: Boolean, + val variations: MutableList = mutableListOf(), + var foundAtTs: Double? = null, + var foundAtFrame: Int? = null + ) + + // Helper for parsing config + private data class ParsedStageInfo( + val stageName: String, + val imgFile: String, + val isOptional: Boolean, + val threshold: Double = 0.8, + val minBrightness: Int = 0, + val isBrightnessCheck: Boolean = false, + val maxBrightness: Int = 255 + ) + + // Helper data class for the merged table + private data class MergedTimelineEvent( + val timestamp: String, + val source: String, + val eventName: String, + var deltaA: String = "-", + var deltaB: String = "-", + val frame: String = "-" + ) + + class BootProfiler(private val resDir: String, private val descFilename: String) { + var stages: List = listOf() + var frameTimeMap: Map = mapOf() + private val launcherStartName = "Launcher start" + private val launcherLoadedName = "Launcher loaded" + private val launcherTopRatio = 0.18 + private val launcherLowerRatio = 0.5 + private val launcherSimMargin = 0.05 + + init { + stages = loadConfig(File(resDir, descFilename)) + } + + // 1. 加载配置 (复刻 Python _load_config) + private fun loadConfig(configFile: File): List { + if (!configFile.exists()) { + log.error("[Error] Description file not found: ${configFile.absolutePath}") + exitProcess(1) + } + + log.info("Loading config file: ${configFile.absolutePath}") + + // 临时存储解析结果 + val parsedStages = mutableListOf() + + try { + configFile.forEachLine { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { + val parts = trimmed.split("|").map { it.trim() } + if (parts.size >= 2) { + val imgName = parts[0] + var desc = parts[1] + val isOptional = desc.endsWith("?") + if (isOptional) { + desc = desc.dropLast(1).trim() + } + + // 解析可选的参数 + var threshold = 0.8 + var minBrightness = 0 + var isBrightnessCheck = false + var maxBrightness = 255 + for (i in 2 until parts.size) { + val param = parts[i] + if (param.startsWith("threshold=")) { + threshold = param.substringAfter("=").toDoubleOrNull() ?: 0.8 + } else if (param.startsWith("min_brightness=")) { + minBrightness = param.substringAfter("=").toIntOrNull() ?: 0 + } else if (param.startsWith("is_brightness_check=")) { + isBrightnessCheck = param.substringAfter("=").equals("true", ignoreCase = true) + } else if (param.startsWith("max_brightness=")) { + maxBrightness = param.substringAfter("=").toIntOrNull() ?: 255 + } + } + + parsedStages.add(ParsedStageInfo(desc, imgName, isOptional, threshold, minBrightness, isBrightnessCheck, maxBrightness)) + } else { + log.warn("[Warning] Skipping malformed line: $line") + } + } + } + } catch (e: Exception) { + log.error("[Error] Failed to read config: ${e.message}") + exitProcess(1) + } + + // 按名称分组 (Group by Name) + val groupedMap = LinkedHashMap() + + for (parsed in parsedStages) { + val imgPath = File(resDir, parsed.imgFile) + if (!imgPath.exists()) { + log.error("[Error] Image file not found: ${imgPath.absolutePath}") + exitProcess(1) + } + + // 读取图片 (灰度) - 如果是亮度检查,可以跳过读取图片 + val tpl = if (!parsed.isBrightnessCheck) { + val mat = imread(imgPath.absolutePath, IMREAD_GRAYSCALE) + if (mat == null || mat.empty()) { + log.error("[Error] Failed to read image: ${imgPath.absolutePath}") + exitProcess(1) + } + mat + } else { + Mat() // 亮度检查不需要模板 + } + + val stage = groupedMap.getOrPut(parsed.stageName) { + LogicalStage(parsed.stageName, parsed.isOptional) + } + stage.variations.add(StageVariation(parsed.imgFile, tpl, parsed.threshold, parsed.minBrightness, parsed.isBrightnessCheck, parsed.maxBrightness)) + } + + val finalStages = groupedMap.values.toList() + finalStages.forEach { stage -> + val files = stage.variations.joinToString(", ") { it.file } + val optStr = if (stage.isOptional) "(Optional)" else "" + log.info(" + Loaded Logical Stage: [${stage.name}] -> [$files] $optStr") + } + + return finalStages + } + + // 2. 加载 CSV (纯 Kotlin 实现,无额外依赖) + fun loadCsvTimestamps(csvPath: File) { + val map = HashMap() + try { + var isHeader = true + csvPath.forEachLine { line -> + if (isHeader) { + // 简单的 Header 检查,假设第一行是 Header + // 如果第一行也是数据,可以去掉这个 check,或者根据内容判断 + if (line.contains("Frame") || line.contains("Timestamp")) { + isHeader = false + return@forEachLine + } + isHeader = false + } + + if (line.isNotBlank()) { + val parts = line.split(",") + if (parts.size >= 2) { + try { + // 假设格式: Frame, Timestamp (可能还有其他列) + val frameId = parts[0].trim().toInt() + val timestamp = parts[1].trim().toDouble() + map[frameId] = timestamp + } catch (e: NumberFormatException) { + // 忽略解析错误的行 + } + } + } + } + this.frameTimeMap = map + log.info("CSV data loaded: ${map.size} frames") + } catch (e: Exception) { + log.error("[Error] Failed to load CSV: ${e.message}") + exitProcess(1) + } + } + + // 3. 运行分析 + fun runAnalysis(videoPath: File, reportPath: File) { + if (stages.isEmpty()) { + log.error("No stages defined, exiting.") + return + } + + // 抑制 FFmpeg 啰嗦的日志 + FFmpegLogCallback.set() + + val grabber = FFmpegFrameGrabber(videoPath) + try { + grabber.start() + } catch (e: Exception) { + log.error("Failed to open video: ${videoPath.absolutePath}") + return + } + + val converter = OpenCVFrameConverter.ToMat() + val totalFrames = grabber.lengthInVideoFrames + var currentTargetIdx = 0 + val launcherMatcher = buildLauncherMatcher() + + log.info("Analyzing video ($totalFrames frames)...") + + // 循环遍历视频 + // 注意:grabber.frameNumber 是当前由 grabber 准备好的帧,通常是按顺序的 + // 为了和 CSV 对齐,我们手动计数循环次数作为 frameIdx + + var frameIdx = 0 + while (true) { + val frame = grabber.grabImage() ?: break + + // 安全检查:如果目标全都找完了 + if (currentTargetIdx >= stages.size) { + log.info("All stages analyzed, stopping.") + break + } + + val srcMat = converter.convert(frame) + if (srcMat == null) { + frameIdx++ + continue + } + + // 转灰度 + val grayMat = Mat() + cvtColor(srcMat, grayMat, COLOR_BGR2GRAY) + + // A. 构建查找窗口 (Search Window) + // 逻辑:包括当前目标,以及紧随其后的所有可选目标,直到遇到下一个必选目标 + val searchWindow = mutableListOf>() + for (i in currentTargetIdx until stages.size) { + val stage = stages[i] + searchWindow.add(i to stage) + if (!stage.isOptional) { + break + } + } + + // B. 在窗口内并行匹配 (寻找 Frame 内的最佳匹配) + var bestScoreInFrame = -1.0 + var bestStageInFrame: LogicalStage? = null + var bestStageIdxInFrame = -1 + + // 临时 Mat 用于存储匹配结果,避免内存泄漏 + val resultMat = Mat() + + for ((idx, stage) in searchWindow) { + // 对该 Stage 的所有变体 (Templates) 进行匹配,取最高分 + var bestScoreForStage = -1.0 + + if (launcherMatcher != null && stage.name == launcherLoadedName) { + val result = launcherMatcher.evaluate(grayMat) + if (result.isLoaded) { + log.info( + "[Frame $frameIdx] ✅ Launcher loaded matched: " + + "loaded=%.3f loading=%.3f edge=%.4f".format( + result.loadedScore, result.loadingScore, result.edgeScore + ) + ) + } + bestScoreForStage = if (result.isLoaded) 1.0 else -1.0 + } else for (variation in stage.variations) { + // 如果是亮度检查模式,用亮度代替模板匹配 + if (variation.isBrightnessCheck) { + val meanBrightness = mean(grayMat)[0] + if (meanBrightness <= variation.maxBrightness) { + bestScoreForStage = 0.99 // 固定高分表示匹配 + log.debug("Brightness check passed: ${meanBrightness.toInt()} <= ${variation.maxBrightness}") + } + continue + } + + // 检查 minBrightness 参数 (用于其他检测) + if (variation.minBrightness > 0) { + val meanBrightness = mean(grayMat)[0] + if (meanBrightness < variation.minBrightness) { + log.debug("Frame brightness (${meanBrightness.toInt()}) below min_brightness (${variation.minBrightness}), skipping") + continue + } + } + + // 标准模板匹配 + matchTemplate(grayMat, variation.tpl, resultMat, TM_CCOEFF_NORMED) + + // minMaxLoc + val minVal = DoubleArray(1) + val maxVal = DoubleArray(1) + minMaxLoc(resultMat, minVal, maxVal, null, null, Mat()) + + val score = maxVal[0] + if (score > bestScoreForStage) { + bestScoreForStage = score + } + } + + // 如果这个 Stage 的得分比当前帧里其他 Stage 都高,记录它 + if (bestScoreForStage > bestScoreInFrame) { + bestScoreInFrame = bestScoreForStage + bestStageInFrame = stage + bestStageIdxInFrame = idx + } + } + + // 释放 OpenCV 资源 (重要!否则内存泄漏) + grayMat.release() + resultMat.release() + // srcMat 是 converter 内部引用的,通常不需要手动 release,但 frame 需要被 GC 处理 + + // C. 检查最佳匹配是否满足阈值 + val threshold = if (bestStageInFrame?.name == launcherLoadedName) { + 0.99 + } else { + bestStageInFrame?.variations?.firstOrNull()?.threshold ?: 0.8 + } + if (bestScoreInFrame >= threshold && bestStageInFrame != null) { + val ts = frameTimeMap[frameIdx] ?: 0.0 + + // 记录数据 + bestStageInFrame.foundAtTs = ts + bestStageInFrame.foundAtFrame = frameIdx + + if (bestStageInFrame.name == launcherLoadedName) { + log.info("[Frame $frameIdx] 🚀 Launcher loaded confirmed") + } else { + log.info("[Frame $frameIdx] 🎯 Captured: ${bestStageInFrame.name} (Score: %.3f)".format(bestScoreInFrame)) + } + + // 指针跳转 + currentTargetIdx = bestStageIdxInFrame + 1 + + if (currentTargetIdx < stages.size) { + // 预览下一个窗口 + val nextWindowNames = mutableListOf() + for (i in currentTargetIdx until stages.size) { + nextWindowNames.add(stages[i].name) + if (!stages[i].isOptional) break + } + log.info("--> New search window: $nextWindowNames") + } + } + + frameIdx++ + // 每隔一定帧数打印一下进度,防止假死 + if (frameIdx % 500 == 0) { + log.info("Processing: $frameIdx / $totalFrames") + } + } + + log.info("") // 换行 + grabber.stop() + grabber.release() + + printReport(reportPath) + } + + // 4. 生成报告 + private fun printReport(reportPath: File) { + val sb = StringBuilder() + sb.append("\n# BOOT PERFORMANCE REPORT\n\n") + // 表头 + sb.append("| Stage Name | EpochTime | Timestamp | Delta (s) | Frame |\n") + sb.append("|---|---|---|---|---|\n") + + // 查找 Boot Logo 时间作为基准 + val bootLogoStage = stages.find { it.name == "Boot Logo" } + val bootLogoTs = bootLogoStage?.foundAtTs + + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + .withZone(ZoneId.systemDefault()) + + for (stage in stages) { + val ts = stage.foundAtTs + val frameNum = stage.foundAtFrame + + var tsDisplay = "Not Found" + var readableTimeStr = "N/A" + var deltaStr = "N/A" + var frameDisplay = "N/A" + + if (ts != null) { + tsDisplay = String.format("%.3f", ts) + + // Readable Time + try { + val instant = Instant.ofEpochMilli((ts * 1000).toLong()) + readableTimeStr = df.format(instant) + } catch (e: Exception) { + readableTimeStr = "Invalid TS" + } + + // Delta + if (bootLogoTs != null) { + val diff = ts - bootLogoTs + deltaStr = String.format("%.3f", diff) + } else { + deltaStr = "N/A" + } + + frameDisplay = frameNum.toString() + } else if (bootLogoTs == null && stage.name != "Boot Logo") { + deltaStr = "N/A (No Logo)" + } + + sb.append( + String.format( + "| %-28s | %-13s | %-23s | %-9s | %-5s |\n", + stage.name, tsDisplay, readableTimeStr, deltaStr, frameDisplay + ) + ) + } + + val finalReport = sb.toString() + (finalReport) + + try { + reportPath.writeText(finalReport) + log.info("Report saved to: ${reportPath.absolutePath}") + } catch (e: Exception) { + log.error("[Error] Failed to save report: ${e.message}") + } + } + + private data class LauncherMatchResult( + val loadedScore: Double, + val loadingScore: Double, + val edgeScore: Double, + val isLoaded: Boolean + ) + + private inner class LauncherMatcher( + val loadedTop: List, + val loadingTop: List, + val simThreshold: Double, + val edgeThreshold: Double + ) { + fun evaluate(grayFrame: Mat): LauncherMatchResult { + val top = cropTop(grayFrame, launcherTopRatio) + val lower = cropLower(grayFrame, launcherLowerRatio) + val loadedScore = loadedTop.maxOfOrNull { matchScore(top, it) } ?: -1.0 + val loadingScore = loadingTop.maxOfOrNull { matchScore(top, it) } ?: -1.0 + val edgeScore = edgeDensity(lower) + val isLoaded = loadedScore >= simThreshold && + edgeScore >= edgeThreshold && + (loadedScore - loadingScore) >= launcherSimMargin + return LauncherMatchResult(loadedScore, loadingScore, edgeScore, isLoaded) + } + } + + private fun buildLauncherMatcher(): LauncherMatcher? { + val loadingStage = stages.find { it.name == launcherStartName } + val loadedStage = stages.find { it.name == launcherLoadedName } + if (loadingStage == null || loadedStage == null) { + return null + } + + val loadingTop = loadingStage.variations.map { cropTop(it.tpl, launcherTopRatio) } + val loadedTop = loadedStage.variations.map { cropTop(it.tpl, launcherTopRatio) } + val simThreshold = calibrateSimThreshold(loadedTop, loadingTop) + val edgeThreshold = calibrateEdgeThreshold(loadedStage.variations.map { it.tpl }, loadingStage.variations.map { it.tpl }) + + val loadingFiles = loadingStage.variations.joinToString(", ") { it.file } + val loadedFiles = loadedStage.variations.joinToString(", ") { it.file } + log.info("Launcher matcher loading templates: $loadingFiles") + log.info("Launcher matcher loaded templates: $loadedFiles") + log.info("Launcher matcher thresholds: sim=%.3f, edge=%.4f".format(simThreshold, edgeThreshold)) + return LauncherMatcher(loadedTop, loadingTop, simThreshold, edgeThreshold) + } + + private fun calibrateSimThreshold(loadedTop: List, loadingTop: List): Double { + if (loadedTop.isEmpty() || loadingTop.isEmpty()) return 0.7 + val llScores = mutableListOf() + for (i in loadedTop.indices) { + for (j in (i + 1) until loadedTop.size) { + llScores.add(matchScore(loadedTop[i], loadedTop[j])) + } + } + val lmScores = mutableListOf() + for (l in loadedTop) { + for (s in loadingTop) { + lmScores.add(matchScore(l, s)) + } + } + if (llScores.isEmpty() || lmScores.isEmpty()) return 0.7 + val minLoaded = llScores.minOrNull() ?: 0.7 + val maxLoading = lmScores.maxOrNull() ?: 0.0 + return if (minLoaded > maxLoading + 0.02) { + (minLoaded + maxLoading) / 2.0 + } else { + maxOf(0.6, (minLoaded + maxLoading) / 2.0) + } + } + + private fun calibrateEdgeThreshold(loaded: List, loading: List): Double { + if (loaded.isEmpty() || loading.isEmpty()) return 0.015 + val loadedVals = loaded.map { edgeDensity(cropLower(it, launcherLowerRatio)) } + val loadingVals = loading.map { edgeDensity(cropLower(it, launcherLowerRatio)) } + val minLoaded = loadedVals.minOrNull() ?: 0.0 + val maxLoading = loadingVals.maxOrNull() ?: 0.0 + return if (minLoaded > maxLoading) { + (minLoaded + maxLoading) / 2.0 + } else { + 0.015 + } + } + + private fun cropTop(gray: Mat, ratio: Double): Mat { + val height = maxOf(1, (gray.rows() * ratio).toInt()) + return gray.rowRange(0, height) + } + + private fun cropLower(gray: Mat, ratio: Double): Mat { + val start = (gray.rows() * (1.0 - ratio)).toInt() + val safeStart = maxOf(0, minOf(start, gray.rows() - 1)) + return gray.rowRange(safeStart, gray.rows()) + } + + private fun matchScore(image: Mat, template: Mat): Double { + val resizedTpl = if (template.rows() != image.rows() || template.cols() != image.cols()) { + val tmp = Mat() + resize(template, tmp, image.size()) + tmp + } else { + template + } + val result = Mat() + matchTemplate(image, resizedTpl, result, TM_CCOEFF_NORMED) + val minVal = DoubleArray(1) + val maxVal = DoubleArray(1) + minMaxLoc(result, minVal, maxVal, null, null, Mat()) + result.release() + if (resizedTpl !== template) { + resizedTpl.release() + } + return maxVal[0] + } + + private fun edgeDensity(gray: Mat): Double { + val edges = Mat() + Canny(gray, edges, 50.0, 150.0) + val count = countNonZero(edges) + val total = edges.rows().toLong() * edges.cols().toLong() + edges.release() + return if (total == 0L) 0.0 else count.toDouble() / total.toDouble() + } + } + + fun analyzeVideo(args: Array) { +// === 入口点 === + if (args.isEmpty()) { + log.info("Usage: analyze video ") + exitProcess(1) + } + + val logDir = args[0] + val dirFile = File(logDir) + + if (!dirFile.exists() || !dirFile.isDirectory) { + log.error("[Error] Directory not found: $logDir") + exitProcess(1) + } + + val videoPath = File(dirFile, "video.mp4") + val csvPath = File(dirFile, "video.csv") + val reportPath = File(dirFile, "video_report.md") + + if (!videoPath.exists()) { + log.error("[Error] video.mp4 not found in $logDir") + exitProcess(1) + } + if (!csvPath.exists()) { + log.error("[Error] video.csv not found in $logDir") + exitProcess(1) + } + +// 运行 + val profiler = BootProfiler(RES_DIR, DESC_FILE) + profiler.loadCsvTimestamps(csvPath) + profiler.runAnalysis(videoPath, reportPath) + } + fun analyzeLog(args: Array) { + if (args.isEmpty()) { + log.info("Usage: analyzeLog ") + return + } + val logDir = args[0] + log.info("Analyzing log: $logDir") + val consoleLogFile = File(logDir, "console.log") + val logcatFile = File(logDir, "logcat.lc") + val outputFile = File(logDir, "log_report.md") + + val startTimestamp = getLastHwInitDdrTimestamp(consoleLogFile) + if (startTimestamp != null) { + log.info("Found last 'DHL PT:' at: $startTimestamp, starting analysis from this point") + } else { + log.warn("No 'DHL PT:' found, analyzing from the beginning") + } + + val consoleLines = if (consoleLogFile.exists()) consoleLogFile.readLines() else emptyList() + val logcatLines = if (logcatFile.exists()) logcatFile.readLines() else emptyList() + + val timestampPattern = Regex("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\]") + val filteredConsoleLines = if (startTimestamp == null) consoleLines else consoleLines.filter { + val match = timestampPattern.find(it) + match != null && match.groupValues[1] >= startTimestamp + } + val filteredLogcatLines = if (startTimestamp == null) logcatLines else logcatLines.filter { + val match = timestampPattern.find(it) + match != null && match.groupValues[1] >= startTimestamp + } + + val logcatPatterns = loadLogcatPatterns() + val deviceInfo = parseDeviceInfo(filteredLogcatLines) + val consoleEvents = parseConsoleLog(filteredConsoleLines) + val (logcatEvents, notFound) = parseLogcatFile(filteredLogcatLines, logcatPatterns) + val allEvents = (consoleEvents + logcatEvents).sortedBy { it.timestamp } + + generateMarkdownReport(allEvents, notFound, deviceInfo, outputFile) + log.info("Analysis complete, report generated at: '${outputFile.absolutePath}'") + } + + private fun getLastHwInitDdrTimestamp(file: File): String? { + if (!file.exists()) { + return null + } + val ddrPattern = Regex("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\]\\s+.*(DHL PT:.*)") + var lastTimestamp: String? = null + file.forEachLine { line -> + ddrPattern.find(line)?.let { + lastTimestamp = it.groupValues[1] + } + } + return lastTimestamp + } + + private fun loadLogcatPatterns(): List { + val patterns = mutableListOf() + val patternFile = File(LOG_PATTERN_FILE) + if (!patternFile.exists()) { + log.warn("Warning: Log pattern file not found: ${patternFile.absolutePath}") + return patterns + } + + patternFile.forEachLine { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { + val parts = trimmed.split('|', limit = 3).map { it.trim() } + if (parts.size == 3) { + val eventName = parts[0] + val singleOccurrence = parts[1].toBoolean() + val regex = parts[2] + patterns.add(EventPattern(eventName, Regex.fromLiteral(regex), singleOccurrence)) + } + } + } + return patterns + } + + private data class FoundEvent(val timestamp: String, val eventName: String, val logLine: String) + + private data class EventPattern(val eventName: String, val pattern: Regex, val isSingleOccurrence: Boolean = true) + + private fun parseConsoleLog(lines: List): List { + if (lines.isEmpty()) { + return emptyList() + } + + val kernelPattern = Regex("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\]\\s+.*(Start kernel at.*)") + val ddrPattern = Regex("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\]\\s+.*(DHL PT:.*)") + var kernelStartEvent: FoundEvent? = null + var ddrInitEvent: FoundEvent? = null + + lines.forEach { line -> + kernelPattern.find(line)?.let { + kernelStartEvent = FoundEvent(it.groupValues[1], "Kernel Start", line.trim()) + } + ddrPattern.find(line)?.let { + ddrInitEvent = FoundEvent(it.groupValues[1], "HW Init", line.trim()) + } + } + + log.info("parseConsoleLog: " + ddrInitEvent.toString()) + return listOfNotNull(ddrInitEvent, kernelStartEvent) + } + + private fun parseLogcatFile(lines: List, logcatPatterns: List): Pair, List> { + if (lines.isEmpty()) { + return Pair(emptyList(), logcatPatterns.filter { it.isSingleOccurrence }) + } + + val foundEvents = mutableListOf() + val patternsToCheck = logcatPatterns.toMutableList() + val timestampPattern = Regex("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\]\\s+(.*)") + + lines.forEach { line -> + timestampPattern.matchEntire(line.trim())?.let { match -> + val timestamp = match.groupValues[1] + val logContent = match.groupValues[2] + + val patternsIterator = patternsToCheck.iterator() + while (patternsIterator.hasNext()) { + val eventDef = patternsIterator.next() + eventDef.pattern.find(logContent)?.let { + var eventName = eventDef.eventName + if (eventDef.eventName == "Manual Event" && it.groupValues.size > 1) { + eventName = "$eventName: ${it.groupValues[1].trim()}" + } + foundEvents.add(FoundEvent(timestamp, eventName, line.trim())) + if (eventDef.isSingleOccurrence) { + patternsIterator.remove() + } + } + } + } + } + return Pair(foundEvents, patternsToCheck.filter { it.isSingleOccurrence }) + } + + private fun parseDeviceInfo(lines: List): Map { + if (lines.isEmpty()) return emptyMap() + + val deviceInfo = mutableMapOf() + val propPattern = Regex("\\[(ro\\.(?:product\\.(?:model|device)))\\]: \\[([^\\]]+)\\]") + val fingerprintPattern = Regex("-Xfingerprint:(.*)") + val desiredProps = setOf("ro.product.model", "ro.product.device") + + for (line in lines) { + propPattern.find(line)?.let { + val key = it.groupValues[1] + if (desiredProps.contains(key)) { + deviceInfo[key] = it.groupValues[2] + } + } + fingerprintPattern.find(line)?.let { + deviceInfo["ro.build.fingerprint"] = it.groupValues[1].trim() + } + if (deviceInfo.keys.containsAll(desiredProps) && deviceInfo.containsKey("ro.build.fingerprint")) { + return deviceInfo //early exit + } + } + return deviceInfo + } + + private fun generateMarkdownReport( + allEvents: List, + notFoundLogcat: List, + deviceInfo: Map, + outputFile: File + ) { + val timeZeroA = allEvents.find { it.eventName == "HW Init" }?.let { + Instant.from(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()).parse(it.timestamp)) + } + val timeZeroB = allEvents.find { it.eventName == "Kernel Start" }?.let { + Instant.from(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()).parse(it.timestamp)) + } + + outputFile.bufferedWriter().use { writer -> + writer.write("# Android Boot Timeline Analysis Report\n\n") + + if (deviceInfo.isNotEmpty()) { + writer.write("## Device and Build Information\n\n") + writer.write("- **Model:** `${deviceInfo.getOrDefault("ro.product.model", "N/A")}`\n") + writer.write("- **Device:** `${deviceInfo.getOrDefault("ro.product.device", "N/A")}`\n") + writer.write("- **Fingerprint:** `${deviceInfo.getOrDefault("ro.build.fingerprint", "N/A")}`\n\n") + } + + writer.write("Source: `console.log` and `logcat.lc`\n\n") + + if (allEvents.isEmpty()) { + writer.write("No key events found in the log files.\n") + return + } + + writer.write("## Timeline Summary\n\n") + writer.write("`Delta(A)`: Relative time based on \"HW Init\"\n") + writer.write("`Delta(B)`: Relative time based on \"Kernel Start\"\n\n") + writer.write("| Timestamp | Delta(A) | Delta(B) | Key Event |\n") + writer.write("|-----------------------------|-----------|-----------|------------------------------------------|\n") + + allEvents.forEach { event -> + val currentTime = Instant.from(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()).parse(event.timestamp)) + + val deltaAStr = timeZeroA?.let { "%.1fs".format((currentTime.toEpochMilli() - it.toEpochMilli()) / 1000.0) } ?: "N/A" + val deltaBStr = timeZeroB?.let { "%.1fs".format((currentTime.toEpochMilli() - it.toEpochMilli()) / 1000.0) } ?: "N/A" + + writer.write( + "| `${event.timestamp}` | `$deltaAStr` | `$deltaBStr` | ${event.eventName} |\n" + ) + } + writer.write("\n") + + writer.write("## Detailed Event Log\n\n") + allEvents.forEach { event -> + writer.write("### ${event.eventName}\n\n") + writer.write("- **Timestamp:** `${event.timestamp}`\n") + writer.write("- **Related Log:**\n") + writer.write(" ```log\n") + writer.write(" ${event.logLine}\n") + writer.write(" ```\n\n") + } + + if (notFoundLogcat.isNotEmpty()) { + writer.write("## Events not found in Logcat\n\n") + writer.write("The following key events were not found in `logcat.lc`:\n\n") + notFoundLogcat.forEach { writer.write("- ${it.eventName}\n") } + } + } + } + + private fun parseMarkdownReport( + reportFile: File, + isLogReport: Boolean + ): Pair> { + if (!reportFile.exists()) return "" to emptyList() + + val events = mutableListOf() + val lines = reportFile.readLines() + val tableSeparatorIndex = lines.indexOfFirst { it.startsWith("|") && it.contains("---") } + + if (tableSeparatorIndex == -1) { // No table found + return lines.joinToString("\n") to emptyList() + } + + // Preamble is everything before the table header line + val preambleLines = lines.subList(0, tableSeparatorIndex - 1) + val preamble = preambleLines.joinToString("\n") + + // Table rows are everything after the separator + val tableLines = lines.subList(tableSeparatorIndex + 1, lines.size) + + for (line in tableLines) { + if (!line.startsWith("|")) continue // Skip non-table lines + + val parts = line.split("|").map { it.trim() } + if (parts.size < 4) continue // Malformed row + + try { + if (isLogReport) { + // Log report format: | `timestamp` | `deltaA` | `deltaB` | eventName | + if (parts.size >= 5) { + val timestamp = parts[1].replace("`", "") + val deltaA = parts[2].replace("`", "") + val deltaB = parts[3].replace("`", "") + val eventName = parts[4] + if (timestamp.isNotBlank() && timestamp != "N/A") { + events.add( + MergedTimelineEvent( + timestamp = timestamp, + source = "Log", + eventName = eventName, + deltaA = deltaA, + deltaB = deltaB + ) + ) + } + } + } else { + // Video report format: | stageName | epochTime | TimeStamp | delta | frame | + if (parts.size >= 6) { + val eventName = parts[1] + val timestamp = parts[3] + val delta = parts[4] // This is delta from boot logo + val frame = parts[5] + if (timestamp.isNotBlank() && timestamp != "N/A") { + events.add( + MergedTimelineEvent( + timestamp = timestamp, + source = "Video", + eventName = eventName, + deltaA = delta, // Re-using deltaA for video's delta + frame = frame + ) + ) + } + } + } + } catch (e: IndexOutOfBoundsException) { + log.warn("Could not parse table row: $line") + } + } + + return preamble to events + } + + + fun mergeReports(args: Array) { + if (args.isEmpty()) { + log.info("Usage: mergeReports ") + return + } + log.info("mergeReports: $args") + val logDir = args[0] + val inVideoReport = File(logDir, "video_report.md") + val inLogReport = File(logDir, "log_report.md") + val mergedReportFile = File(logDir, "merged_boot_report.md") + + val (videoPreamble, videoEvents) = parseMarkdownReport(inVideoReport, isLogReport = false) + val (logPreamble, logEvents) = parseMarkdownReport(inLogReport, isLogReport = true) + + val allEvents = (videoEvents + logEvents).sortedBy { it.timestamp } + + val timeZeroA = allEvents.find { it.eventName.trim() == "HW Init" }?.let { + try { + Instant.from( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()) + .parse(it.timestamp) + ) + } catch (e: Exception) { + null + } + } + val timeZeroB = allEvents.find { it.eventName.trim() == "Kernel Start" }?.let { + try { + Instant.from( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()) + .parse(it.timestamp) + ) + } catch (e: Exception) { + null + } + } + + val recalculatedEvents = allEvents.map { event -> + val currentTime = try { + Instant.from( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()) + .parse(event.timestamp) + ) + } catch (e: Exception) { + null + } + + val deltaAStr = if (currentTime != null && timeZeroA != null) { + "%.3fs".format((currentTime.toEpochMilli() - timeZeroA.toEpochMilli()) / 1000.0) + } else { + "-" + } + + val deltaBStr = if (currentTime != null && timeZeroB != null) { + "%.3fs".format((currentTime.toEpochMilli() - timeZeroB.toEpochMilli()) / 1000.0) + } else { + "-" + } + + event.copy( + deltaA = deltaAStr, + deltaB = deltaBStr, + frame = if (event.frame == "N/A") "-" else event.frame + ) + } + + mergedReportFile.bufferedWriter().use { writer -> + writer.write("# Merged Boot Timeline Analysis Report\n\n") + + // Write preambles, but remove the original titles to avoid duplication + writer.write(videoPreamble.replace("# BOOT PERFORMANCE REPORT", "").trim()) + writer.write("\n\n") + writer.write(logPreamble.replace("# Android Boot Timeline Analysis Report", "").trim()) + writer.write("\n\n") + + writer.write("## Merged Timeline Summary\n\n") + writer.write("`Delta(A)`: Relative time based on \"HW Init\"\n") + writer.write("`Delta(B)`: Relative time based on \"Kernel Start\"\n\n") + + // Unified table header + writer.write("| TimeStamp | Log Event | Visual Event | Delta(A) | Delta(B) | Frame |\n") + writer.write("|-----------------------------|-----------------------------|-----------------------------|-----------|-----------|-------|\n") + + recalculatedEvents.forEach { event -> + val logEvent = if (event.source == "Log") event.eventName else "-" + val visualEvent = if (event.source == "Video") event.eventName else "-" + writer.write( + String.format( + "| %-27s | %-27s | %-27s | %-9s | %-9s | %-5s |\n", + event.timestamp, + logEvent, + visualEvent, + event.deltaA, + event.deltaB, + event.frame + ) + ) + } + } + log.info("Merged report created at: ${mergedReportFile.absolutePath}") + } + + companion object { + val log = LoggerFactory.getLogger(VideoAnalyzer::class.java) + } +} diff --git a/lazybox/src/main/resources/boot_analysis.py b/lazybox/src/main/resources/boot_analysis.py new file mode 100755 index 0000000..d7cebc1 --- /dev/null +++ b/lazybox/src/main/resources/boot_analysis.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python3 + +import argparse +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +import pandas as pd + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +@dataclass(frozen=True) +class ReportRef: + build_type: str + run_id: str + path: Path + + +def _clean_event_name(name: str) -> str: + s = name.strip().strip("`") + if s in ("-", "", "N/A"): + return "" + s = re.sub(r"\s+", " ", s) + s = re.sub(r"\s*\(.*?\)\s*$", "", s).strip() + return s + + +def _parse_seconds(token: str) -> Optional[float]: + t = token.strip().strip("`") + if t in ("-", "", "N/A"): + return None + t = t.replace("s", "").strip() + try: + return float(t) + except ValueError: + return None + + +def _iter_markdown_table_rows(lines: List[str]) -> Iterable[List[str]]: + for line in lines: + stripped = line.strip("\n") + if not stripped.strip().startswith("|"): + continue + if "---" in stripped: + continue + parts = [p.strip() for p in stripped.split("|")] + parts = [p for p in parts if p != ""] + if not parts: + continue + yield parts + + +def parse_merged_boot_report(path: Path) -> List[Tuple[str, float, int]]: + text = path.read_text(encoding="utf-8", errors="ignore") + lines = text.splitlines() + + section_idx = None + for i, line in enumerate(lines): + if line.strip() == "## Merged Timeline Summary": + section_idx = i + break + + if section_idx is None: + return [] + + table_start = None + for i in range(section_idx + 1, len(lines)): + if lines[i].lstrip().startswith("|") and "TimeStamp" in lines[i] and "Delta(A)" in lines[i]: + table_start = i + break + + if table_start is None: + return [] + + table_lines: List[str] = [] + for i in range(table_start, len(lines)): + line = lines[i] + if i > table_start and not line.strip(): + break + if line.lstrip().startswith("|"): + table_lines.append(line) + + if not table_lines: + return [] + + header = [h.strip() for h in table_lines[0].split("|")] + header = [h for h in header if h] + cols: Dict[str, int] = {name: idx for idx, name in enumerate(header)} + + def idx(col: str) -> Optional[int]: + return cols.get(col) + + i_log = idx("Log Event") + i_vis = idx("Visual Event") + i_delta_a = idx("Delta(A)") + + if i_delta_a is None or (i_log is None and i_vis is None): + return [] + + out: List[Tuple[str, float, int]] = [] + + for row_idx, parts in enumerate(_iter_markdown_table_rows(table_lines[1:])): + if len(parts) <= i_delta_a: + continue + + log_event = parts[i_log] if i_log is not None and len(parts) > i_log else "" + vis_event = parts[i_vis] if i_vis is not None and len(parts) > i_vis else "" + + vis_clean = _clean_event_name(vis_event) + log_clean = _clean_event_name(log_event) + event = vis_clean if vis_clean else log_clean + if not event: + continue + + t = _parse_seconds(parts[i_delta_a]) + if t is None: + continue + + out.append((event, t, row_idx)) + + return out + + +def find_reports(root: Path, build_type: str) -> List[ReportRef]: + out: List[ReportRef] = [] + if not root.exists() or not root.is_dir(): + return out + + # Only look into first-level subdirectories + for p in root.iterdir(): + if p.is_dir(): + report_file = p / "merged_boot_report.md" + if report_file.exists(): + run_id = p.name + out.append(ReportRef(build_type=build_type, run_id=run_id, path=report_file)) + + out.sort(key=lambda r: (r.run_id, str(r.path))) + return out + + +def build_dataframe(reports: List[ReportRef]) -> pd.DataFrame: + rows: List[Dict[str, object]] = [] + + for ref in reports: + try: + events = parse_merged_boot_report(ref.path) + except Exception as e: + print(f"[warn] failed to parse {ref.path}: {e}", file=sys.stderr) + continue + + for event, t, raw_idx in events: + rows.append( + { + "build_type": ref.build_type, + "run_id": ref.run_id, + "event": event, + "time": float(t), + "raw_idx": raw_idx, + } + ) + + df = pd.DataFrame(rows) + if df.empty: + return df + + df = df.dropna(subset=["build_type", "run_id", "event", "time"]) + df["event"] = df["event"].astype(str) + df["event_key"] = df["event"].str.lower().str.strip() + + df = ( + df.sort_values(["build_type", "run_id", "event_key", "time"], ascending=True) + .groupby(["build_type", "run_id", "event_key"], as_index=False) + .first() + ) + + return df + + +def safe_to_markdown(df: pd.DataFrame) -> str: + if df.empty: + return "(no data)" + try: + return df.to_markdown(index=False) + except Exception: + cols = list(df.columns) + rows = df.values.tolist() + header = "| " + " | ".join(str(c) for c in cols) + " |\n" + sep = "|" + "|".join(["---"] * len(cols)) + "|\n" + body = "".join("| " + " | ".join(str(v) for v in r) + " |\n" for r in rows) + return header + sep + body + + +def _format_float(v: object, ndigits: int = 3) -> str: + try: + if pd.isna(v): + return "N/A" + return f"{float(v):.{ndigits}f}" + except Exception: + return "N/A" + + +def _top_changes(df: pd.DataFrame, abs_col: str, label_col: str, n: int = 5) -> Tuple[pd.DataFrame, pd.DataFrame]: + if df.empty or abs_col not in df.columns: + return pd.DataFrame(), pd.DataFrame() + + tmp = df.copy() + tmp[abs_col] = pd.to_numeric(tmp[abs_col], errors="coerce") + tmp = tmp.dropna(subset=[abs_col]) + if tmp.empty: + return pd.DataFrame(), pd.DataFrame() + + improved = tmp[tmp[abs_col] > 0].sort_values(abs_col, ascending=False).head(n) + regressed = tmp[tmp[abs_col] < 0].sort_values(abs_col, ascending=True).head(n) + + cols = [c for c in [label_col, abs_col] if c in tmp.columns] + return improved[cols], regressed[cols] + + +def generate_markdown_report( + *, + out_path: Path, + a_name: str, + b_name: str, + a_dir: Path, + b_dir: Path, + a_reports: List[ReportRef], + b_reports: List[ReportRef], + df: pd.DataFrame, + all_events_avg: pd.DataFrame, + milestones_df: pd.DataFrame, + phases_df: pd.DataFrame, + milestones_png: Path, + phases_png: Path, +) -> None: + total_runs = df[["build_type", "run_id"]].drop_duplicates().shape[0] if not df.empty else 0 + runs_by_build = ( + df[["build_type", "run_id"]].drop_duplicates().groupby("build_type").size().to_dict() if not df.empty else {} + ) + events_by_build = df.groupby("build_type").size().to_dict() if not df.empty else {} + + missing_events = pd.DataFrame() + if not df.empty: + cover = ( + df.groupby(["build_type", "event_key"], as_index=False) + .size() + .rename(columns={"size": "count"}) + ) + cover = cover.pivot(index="event_key", columns="build_type", values="count").fillna(0).astype(int) + cover = cover.reset_index() + cover = cover.rename(columns={"event_key": "event_id"}) + missing_events = cover[(cover.get(a_name, 0) == 0) | (cover.get(b_name, 0) == 0)].copy() + + top_ms_improve, top_ms_regress = _top_changes(milestones_df, "abs_improvement", "milestone", n=5) + top_ph_improve, top_ph_regress = _top_changes(phases_df, "abs_improvement", "phase", n=5) + + with out_path.open("w", encoding="utf-8") as f: + f.write(f"# Boot Time A/B Comparison Report\n\n") + f.write(f"This report compares boot-time events between **{a_name}** (A) and **{b_name}** (B).\n\n") + + f.write("## 1. Inputs\n\n") + f.write(f"- **A**: `{a_name}` @ `{a_dir}`\n") + f.write(f"- **B**: `{b_name}` @ `{b_dir}`\n") + f.write(f"- **Found reports**: A={len(a_reports)}, B={len(b_reports)}\n") + f.write("\n") + + f.write("## 2. Data Coverage\n\n") + f.write(f"- **Parsed runs**: {total_runs}\n") + f.write(f"- **Runs by build**: A={runs_by_build.get(a_name, 0)}, B={runs_by_build.get(b_name, 0)}\n") + f.write(f"- **Parsed event rows**: A={events_by_build.get(a_name, 0)}, B={events_by_build.get(b_name, 0)}\n\n") + if not missing_events.empty: + f.write("### Events missing in either build (coverage=0)\n\n") + f.write(safe_to_markdown(missing_events)) + f.write("\n") + + f.write("## 3. Average Time for Each Event (All Events)\n\n") + f.write(safe_to_markdown(all_events_avg)) + f.write("\n") + + f.write("## 4. Key Milestones\n\n") + f.write(safe_to_markdown(milestones_df)) + f.write("\n") + if milestones_png.exists(): + f.write("### Milestones Chart\n\n") + f.write(f"![]({milestones_png.name})\n\n") + + f.write("## 5. Boot Phases\n\n") + f.write(safe_to_markdown(phases_df)) + f.write("\n") + if phases_png.exists(): + f.write("### Phases Chart\n\n") + f.write(f"![]({phases_png.name})\n\n") + + f.write("## 6. Observations\n\n") + f.write(f"- **Improvement definition**: `abs_improvement = avg_{b_name} - avg_{a_name}`. Positive means A is faster than B.\n") + + if not top_ms_improve.empty: + f.write("\n### Top milestone improvements (A faster)\n\n") + f.write(safe_to_markdown(top_ms_improve)) + f.write("\n") + if not top_ms_regress.empty: + f.write("\n### Top milestone regressions (A slower)\n\n") + f.write(safe_to_markdown(top_ms_regress)) + f.write("\n") + + if not top_ph_improve.empty: + f.write("\n### Top phase improvements (A faster)\n\n") + f.write(safe_to_markdown(top_ph_improve)) + f.write("\n") + if not top_ph_regress.empty: + f.write("\n### Top phase regressions (A slower)\n\n") + f.write(safe_to_markdown(top_ph_regress)) + f.write("\n") + + +def _pick_time_for_event(run_df: pd.DataFrame, event_key: str) -> Optional[float]: + row = run_df.loc[run_df["event_key"] == event_key] + if row.empty: + return None + return float(row.iloc[0]["time"]) + + +def _normalize_event_keys(spec: object) -> List[str]: + if spec is None: + return [] + if isinstance(spec, str): + return [spec] + if isinstance(spec, (list, tuple)): + out: List[str] = [] + for x in spec: + if isinstance(x, str) and x.strip(): + out.append(x) + return out + return [] + + +def _pick_time_for_any_event(run_df: pd.DataFrame, event_keys: object) -> Optional[float]: + for k in _normalize_event_keys(event_keys): + t = _pick_time_for_event(run_df, k) + if t is not None: + return t + return None + + +def compute_phase_durations(df: pd.DataFrame, phases: Dict[str, Tuple[object, Optional[object]]]) -> pd.DataFrame: + rows: List[Dict[str, object]] = [] + + for (build_type, run_id), run_df in df.groupby(["build_type", "run_id"]): + for phase_name, (end_keys, start_keys) in phases.items(): + end_t = _pick_time_for_any_event(run_df, end_keys) + start_t = 0.0 if start_keys is None else _pick_time_for_any_event(run_df, start_keys) + if end_t is None or start_t is None: + continue + duration = end_t - start_t + if duration < 0: + continue + rows.append( + { + "build_type": build_type, + "run_id": run_id, + "phase": phase_name, + "duration": duration, + } + ) + + out = pd.DataFrame(rows) + if out.empty: + return out + out["phase"] = out["phase"].astype(str) + return out + + +def milestone_summary(df: pd.DataFrame, milestones: List[str], build_types: List[str]) -> pd.DataFrame: + if df.empty: + return df + + wanted = [m.lower().strip() for m in milestones] + sub = df[df["event_key"].isin(wanted)].copy() + if sub.empty: + return sub + + avg = ( + sub.groupby(["build_type", "event_key"], as_index=False) + .agg({"time": "mean", "raw_idx": "mean"}) + .rename(columns={"event_key": "event"}) + ) + + avg["event"] = avg["event"].astype(str) + avg["time"] = avg["time"].astype(float) + + pivot = avg.pivot(index="event", columns="build_type", values="time") + + a, b = build_types[0], build_types[1] + if a not in pivot.columns: + pivot[a] = pd.NA + if b not in pivot.columns: + pivot[b] = pd.NA + + pivot = pivot[[a, b]] + pivot = pivot.reset_index() + pivot = pivot.rename(columns={"event": "milestone", a: f"avg_{a}", b: f"avg_{b}"}) + + # sort by average raw_idx to keep original chronological order + order_map = avg.groupby("event")["raw_idx"].mean().to_dict() + pivot["sort_key"] = pivot["milestone"].str.lower().str.strip().map(order_map) + # If some milestones are not in order_map, they will be NaN in sort_key + # We can fill them with a large value to put them at the end, or just sort + pivot = pivot.sort_values("sort_key").drop(columns=["sort_key"]) + + pivot["abs_improvement"] = pivot[f"avg_{b}"] - pivot[f"avg_{a}"] + denom = pd.to_numeric(pivot[f"avg_{b}"], errors="coerce") + pivot["pct_improvement"] = (pivot["abs_improvement"] / denom) * 100.0 + pivot.loc[denom.isna() | (denom <= 0), "pct_improvement"] = pd.NA + + return pivot + + +def event_average_summary(df: pd.DataFrame, build_types: List[str]) -> pd.DataFrame: + if df.empty: + return df + + a, b = build_types[0], build_types[1] + + def _mode(series: pd.Series) -> str: + s = series.dropna().astype(str) + if s.empty: + return "" + return s.value_counts().index[0] + + display = df.groupby("event_key")["event"].apply(_mode).reset_index(name="event") + avg = df.groupby(["build_type", "event_key"], as_index=False).agg({"time": "mean", "raw_idx": "mean"}) + + pivot = avg.pivot(index="event_key", columns="build_type", values="time") + if a not in pivot.columns: + pivot[a] = pd.NA + if b not in pivot.columns: + pivot[b] = pd.NA + + pivot = pivot[[a, b]].reset_index() + pivot = pivot.rename(columns={a: f"avg_{a}", b: f"avg_{b}"}) + pivot = pivot.merge(display, on="event_key", how="left") + + # sort by average raw_idx to keep original chronological order + order_map = avg.groupby("event_key")["raw_idx"].mean().sort_values().index.tolist() + pivot["event_key"] = pd.Categorical(pivot["event_key"], categories=order_map, ordered=True) + pivot = pivot.sort_values("event_key") + pivot = pivot[["event", "event_key", f"avg_{a}", f"avg_{b}"]] + pivot["event_key"] = pivot["event_key"].astype(str) # Convert back to string to avoid categorical issues later + pivot = pivot.rename(columns={"event_key": "event_id"}) + return pivot + + +def phase_summary(phase_df: pd.DataFrame, build_types: List[str], phases: Dict[str, object]) -> pd.DataFrame: + if phase_df.empty: + return phase_df + + avg = ( + phase_df.groupby(["build_type", "phase"], as_index=False)["duration"] + .mean() + .rename(columns={"duration": "avg_duration"}) + ) + + pivot = avg.pivot(index="phase", columns="build_type", values="avg_duration") + a, b = build_types[0], build_types[1] + if a not in pivot.columns: + pivot[a] = pd.NA + if b not in pivot.columns: + pivot[b] = pd.NA + pivot = pivot[[a, b]].reset_index() + pivot = pivot.rename(columns={a: f"avg_{a}", b: f"avg_{b}"}) + + # since phases are defined in a dictionary, they follow the defined sequence. + # We should respect the dictionary order for the final table and stacked chart. + phase_order = list(phases.keys()) + pivot["phase"] = pd.Categorical(pivot["phase"], categories=phase_order, ordered=True) + pivot = pivot.sort_values("phase") + pivot["phase"] = pivot["phase"].astype(str) # Convert back to string to avoid categorical issues later + + pivot["abs_improvement"] = pivot[f"avg_{b}"] - pivot[f"avg_{a}"] + denom = pd.to_numeric(pivot[f"avg_{b}"], errors="coerce") + pivot["pct_improvement"] = (pivot["abs_improvement"] / denom) * 100.0 + pivot.loc[denom.isna() | (denom <= 0), "pct_improvement"] = pd.NA + return pivot + + +def plot_milestones(milestone_df: pd.DataFrame, a: str, b: str, out_path: Path) -> None: + if milestone_df.empty: + return + + labels = milestone_df["milestone"].astype(str).tolist() + y = list(range(len(labels))) + + a_vals = pd.to_numeric(milestone_df[f"avg_{a}"], errors="coerce").fillna(0.0) + b_vals = pd.to_numeric(milestone_df[f"avg_{b}"], errors="coerce").fillna(0.0) + + height = 0.35 + + fig, ax = plt.subplots(figsize=(10, max(3.5, 0.5 * len(labels)))) + ax.barh([yy - height / 2 for yy in y], a_vals, height=height, label=a) + ax.barh([yy + height / 2 for yy in y], b_vals, height=height, label=b) + + ax.set_yticks(y) + ax.set_yticklabels(labels) + ax.invert_yaxis() + ax.set_xlabel("Average time (s)") + ax.set_title(f"Boot milestones comparison: {a} vs {b}") + ax.legend() + fig.tight_layout() + fig.savefig(out_path, dpi=150) + plt.close(fig) + + +def plot_phases(phase_df: pd.DataFrame, a: str, b: str, out_path: Path) -> None: + if phase_df.empty: + return + + phases = phase_df["phase"].astype(str).tolist() + + avg_a = pd.to_numeric(phase_df[f"avg_{a}"], errors="coerce").fillna(0.0).tolist() + avg_b = pd.to_numeric(phase_df[f"avg_{b}"], errors="coerce").fillna(0.0).tolist() + + fig, ax = plt.subplots(figsize=(10, 4.5)) + + bottoms_a = 0.0 + bottoms_b = 0.0 + + cmap = plt.get_cmap("tab20") + for i, ph in enumerate(phases): + color = cmap(i % 20) + ax.bar([a, b], [avg_a[i], avg_b[i]], bottom=[bottoms_a, bottoms_b], color=color, label=ph) + bottoms_a += avg_a[i] + bottoms_b += avg_b[i] + + ax.set_ylabel("Average duration (s)") + ax.set_title(f"Boot phases (stacked): {a} vs {b}") + + ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left") + + fig.tight_layout() + fig.savefig(out_path, dpi=150) + plt.close(fig) + + +def main(argv: Optional[List[str]] = None) -> int: + p = argparse.ArgumentParser(prog="boot_analysis.py") + p.add_argument("--a-name", required=True, help="Name of build A (baseline)") + p.add_argument("--a-dir", required=True, help="Directory of build A (will be searched recursively)") + p.add_argument("--b-name", required=True, help="Name of build B (comparison)") + p.add_argument("--b-dir", required=True, help="Directory of build B (will be searched recursively)") + p.add_argument("--out-dir", default=".", help="Output directory for PNG charts") + p.add_argument("--report-name", default="boot_ab_compare_report.md", help="Markdown report filename under out-dir") + p.add_argument( + "--milestone", + action="append", + dest="milestones", + help="Milestone event name (can be repeated). Default: Boot Logo, bootanim start, bootanim end, Launcher start, Launcher loaded", + ) + + args = p.parse_args(argv) + + a_name = args.a_name + b_name = args.b_name + a_dir = Path(args.a_dir) + b_dir = Path(args.b_dir) + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + report_name = args.report_name + + milestones = args.milestones or [ + "Boot Logo", + "bootanim start", + "bootanim end", + "Launcher start", + "Launcher loaded", + ] + + phases: Dict[str, Tuple[object, Optional[object]]] = { + "Boot Logo": ("boot logo", None), + "Animation Start": ("bootanim start", "boot logo"), + "Animation Duration": ("bootanim end", "bootanim start"), + "Launcher Loading": (["launcher loaded", "launcher start"], "bootanim end"), + } + + a_reports = find_reports(a_dir, a_name) + b_reports = find_reports(b_dir, b_name) + + print(f"[info] A: name={a_name} dir={a_dir} reports={len(a_reports)}") + print(f"[info] B: name={b_name} dir={b_dir} reports={len(b_reports)}") + + df = build_dataframe(a_reports + b_reports) + if df.empty: + print("[error] no events parsed", file=sys.stderr) + return 2 + + print("\n## Average time for each event (all events)\n") + ev = event_average_summary(df, build_types=[a_name, b_name]) + ev_display = ev.copy() + if not ev_display.empty: + for c in [f"avg_{a_name}", f"avg_{b_name}"]: + if c in ev_display.columns: + ev_display[c] = pd.to_numeric(ev_display[c], errors="coerce").round(3) + print(safe_to_markdown(ev_display)) + + print("\n## Average milestone timings\n") + ms = milestone_summary(df, milestones, build_types=[a_name, b_name]) + ms_display = ms.copy() + if not ms_display.empty: + for c in [f"avg_{a_name}", f"avg_{b_name}", "abs_improvement", "pct_improvement"]: + if c in ms_display.columns: + ms_display[c] = pd.to_numeric(ms_display[c], errors="coerce").round(3) + print(safe_to_markdown(ms_display)) + + phase_df = compute_phase_durations(df, phases) + print("\n## Average phase durations\n") + ph = phase_summary(phase_df, build_types=[a_name, b_name], phases=phases) + ph_display = ph.copy() + if not ph_display.empty: + for c in [f"avg_{a_name}", f"avg_{b_name}", "abs_improvement", "pct_improvement"]: + if c in ph_display.columns: + ph_display[c] = pd.to_numeric(ph_display[c], errors="coerce").round(3) + print(safe_to_markdown(ph_display)) + + milestones_png = out_dir / "boot_milestones_comparison_v2.png" + if not ms.empty: + plot_milestones(ms, a_name, b_name, milestones_png) + print(f"\n[info] saved: {milestones_png}") + + phases_png = out_dir / "boot_phases_comparison_v2.png" + if not ph.empty: + plot_phases(ph, a_name, b_name, phases_png) + print(f"[info] saved: {phases_png}") + + report_path = out_dir / report_name + try: + generate_markdown_report( + out_path=report_path, + a_name=a_name, + b_name=b_name, + a_dir=a_dir, + b_dir=b_dir, + a_reports=a_reports, + b_reports=b_reports, + df=df, + all_events_avg=ev_display, + milestones_df=ms_display, + phases_df=ph_display, + milestones_png=milestones_png, + phases_png=phases_png, + ) + print(f"[info] saved: {report_path}") + except Exception as e: + print(f"[warn] failed to write markdown report: {e}", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lazybox/src/main/resources/logback.xml b/lazybox/src/main/resources/logback.xml index caa698c..11147a2 100644 --- a/lazybox/src/main/resources/logback.xml +++ b/lazybox/src/main/resources/logback.xml @@ -1,7 +1,7 @@ - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n