laybox: add more commands

- gki
- boot timing analyzer
master
cfig 7 months ago
parent dafd084134
commit 8e6ba4662f
No known key found for this signature in database
GPG Key ID: B104C307F0FDABB7

@ -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.

@ -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<String>, 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<String> {
return runHostCommand("adb", "shell", command).output
}
fun findApexFiles(): List<String> {
val apexFiles = mutableListOf<String>()
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<String> {
return executeOnDevice("mount | grep apex").filter { it.isNotBlank() }
}
fun findPermissionController(): List<String> {
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<String>) {
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()
}
}
}
}

@ -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<String>) {
println("Usage: args: (Array<String>) ...")
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 <dir>")
@ -31,6 +32,12 @@ fun main(args: Array<String>) {
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] <log_dir> : analyze device boot performance from video recording and/or logs")
println(" default to analyze both video and logs")
println("batch_analyze <dir> : analyze every immediate subdirectory under <dir>")
println("stat outlier <dir> : generate boot_time_stability_report.md for all runs under <dir>")
println("stat compare <a_dir> <b_dir> [--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<String>) {
if (args[0] == "ina") {
InaSensor().run()
}
if (args[0] == "analyze") {
val subCommand = args.getOrNull(1)
if (subCommand == "log") {
// analyze log <log_dir>
VideoAnalyzer().analyzeLog(args.drop(2).toTypedArray())
} else if (subCommand == "video") {
// analyze video <log_dir>
VideoAnalyzer().analyzeVideo(args.drop(2).toTypedArray())
} else if (subCommand == "merge") {
VideoAnalyzer().mergeReports(args.drop(2).toTypedArray())
} else {
// analyze <log_dir>
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 <parent_dir>")
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 <dir> | stat compare <a_dir> <b_dir>")
}
}
if (args[0] == "apex") {
Apex().run(args.drop(1).toTypedArray())
}
}

@ -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<String>) {
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
printUsage()
return
}
val positionals = mutableListOf<String>()
val milestones = mutableListOf<String>()
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_dir> <b_dir> [--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, "_")
}
}

@ -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<String, Double>)
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<String, Double> {
val found = mutableMapOf<String, Double>()
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>): 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>): Double = if (values.isEmpty()) 0.0 else values.sum() / values.size
private fun sampleStd(values: List<Double>): 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<BootRun>, 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<BootRun>): 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, StageStats>): 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<String>) {
if (args.isEmpty()) {
System.err.println("Usage: lazybox stat outlier <directory>")
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<BootRun>()
val skippedRuns = mutableListOf<String>()
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<Outlier>()
val outlierRunNames = mutableSetOf<String>()
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}")
}
}

@ -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<Branch>
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Branch(
val name: String,
val kernel_version: String,
val releases: List<Release>
)
@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 <dir> # 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<Branch>
)
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<Release>
)
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 -- <dir> # 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 = "<code translate=\"no\" dir=\"ltr\">"
val startIndex = content.indexOf(jsonStartMarker)
if (startIndex != -1) {
val fromStart = content.substring(startIndex + jsonStartMarker.length)
jsonString = fromStart.substringBefore("</code>").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 = "<code translate=\"no\" dir=\"ltr\">"
val startIndex = content.indexOf(jsonStartMarker)
if (startIndex != -1) {
val fromStart = content.substring(startIndex + jsonStartMarker.length)
jsonString = fromStart.substringBefore("</code>").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<String>()
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<String>()
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<String>) {
// --- 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)
}
}

@ -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())

@ -1,7 +1,7 @@
<configuration debug="false">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n</pattern>
</encoder>
</appender>

Loading…
Cancel
Save