parent
dafd084134
commit
8e6ba4662f
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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"\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"\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())
|
||||
Loading…
Reference in New Issue