1 /* <lambda>null2 * Copyright 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.benchmark 18 19 import android.os.Bundle 20 import android.util.Log 21 import androidx.annotation.RestrictTo 22 import androidx.benchmark.Markdown.createFileLink 23 import androidx.test.platform.app.InstrumentationRegistry 24 import java.lang.StringBuilder 25 import java.util.Locale 26 import org.jetbrains.annotations.TestOnly 27 28 /** Wrapper for multi studio version link format */ 29 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 30 data class IdeSummaryPair(val summaryV2: String, val summaryV3: String = summaryV2) { 31 constructor( 32 v2lines: List<String>, 33 v3lines: List<String> = v2lines, 34 ) : this( 35 summaryV2 = v2lines.joinToString("\n"), 36 summaryV3 = v3lines.joinToString("\n"), 37 ) 38 39 /** Fallback for very old versions of Studio */ 40 val summaryV1: String 41 get() = summaryV2 42 } 43 44 /** Provides a way to capture all the instrumentation results which needs to be reported. */ 45 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 46 class InstrumentationResultScope(val bundle: Bundle = Bundle()) { 47 reportIdeSummarynull48 private fun reportIdeSummary( 49 /** 50 * V2 output string, supports linking to files in the output dir via links of the format 51 * `[link](file://<relative-path-to-trace>`). 52 * 53 * @see LinkFormat.V2 54 */ 55 summaryV2: String, 56 /** 57 * V3 output string, supports linking to files with query parameters of the format 58 * `[link](uri://<relative-path-to-trace>?<queryParam>=<queryParamValue>`). 59 * 60 * @see TraceDeepLink 61 * @see LinkFormat.V3 62 */ 63 summaryV3: String, 64 ) { 65 bundle.putString(IDE_V1_SUMMARY_KEY, summaryV2) // deprecating v1 with a "graceful" fallback 66 67 // Outputs.outputDirectory is safe to use in the context of Studio currently. 68 // This is because AGP does not populate the `additionalTestOutputDir` argument. 69 bundle.putString(IDE_V2_OUTPUT_DIR_PATH_KEY, Outputs.outputDirectory.absolutePath) 70 bundle.putString(IDE_V2_SUMMARY_KEY, summaryV2) 71 bundle.putString(IDE_V3_OUTPUT_DIR_PATH_KEY, Outputs.outputDirectory.absolutePath) 72 bundle.putString(IDE_V3_SUMMARY_KEY, summaryV3) 73 } 74 reportSummaryToIdenull75 fun reportSummaryToIde( 76 warningMessage: String? = null, 77 testName: String? = null, 78 message: String? = null, 79 measurements: Measurements? = null, 80 iterationTracePaths: List<String>? = null, 81 profilerResults: List<Profiler.ResultFile> = emptyList(), 82 insightSummaries: List<InsightSummary> = emptyList(), 83 useTreeDisplayFormat: Boolean = false 84 ) { 85 if (warningMessage != null) { 86 InstrumentationResults.scheduleIdeWarningOnNextReport(warningMessage) 87 } 88 val summaryPair = 89 InstrumentationResults.ideSummary( 90 testName = testName, 91 message = message, 92 measurements = measurements, 93 iterationTracePaths = iterationTracePaths, 94 profilerResults = profilerResults, 95 insightSummaries = insightSummaries, 96 useTreeDisplayFormat = useTreeDisplayFormat 97 ) 98 reportIdeSummary(summaryV2 = summaryPair.summaryV2, summaryV3 = summaryPair.summaryV3) 99 } 100 fileRecordnull101 public fun fileRecord(key: String, path: String) { 102 bundle.putString("additionalTestOutputFile_$key", path) 103 } 104 105 internal companion object { 106 private const val IDE_V1_SUMMARY_KEY = "android.studio.display.benchmark" 107 108 private const val IDE_V2_OUTPUT_DIR_PATH_KEY = 109 "android.studio.v2display.benchmark.outputDirPath" 110 private const val IDE_V2_SUMMARY_KEY = "android.studio.v2display.benchmark" 111 112 private const val IDE_V3_OUTPUT_DIR_PATH_KEY = 113 "android.studio.v3display.benchmark.outputDirPath" 114 private const val IDE_V3_SUMMARY_KEY = "android.studio.v3display.benchmark" 115 } 116 } 117 118 /** Provides way to report additional results via `Instrumentation.sendStatus()` / `addResult()`. */ 119 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 120 object InstrumentationResults { 121 122 /** 123 * Bundle containing values to be reported at end of run, instead of for each test. 124 * 125 * See androidx.benchmark.junit.InstrumentationResultsRunListener 126 */ 127 val runEndResultBundle: Bundle = Bundle() 128 129 /** Creates an Instrumentation Result. */ instrumentationReportnull130 fun instrumentationReport(block: InstrumentationResultScope.() -> Unit) { 131 val scope = InstrumentationResultScope() 132 block.invoke(scope) 133 reportBundle(scope.bundle) 134 } 135 136 /** Simple single line benchmark output */ ideSummaryBasicMicronull137 internal fun ideSummaryBasicMicro( 138 benchmarkName: String, 139 nanos: Double, 140 allocations: Double?, 141 profilerResults: List<Profiler.ResultFile>, 142 ): String { 143 // for readability, report nanos with 10ths only if less than 100 144 var output = 145 if (nanos >= 100.0) { 146 // 13 alignment is enough for ~10 seconds 147 "%,13d ns".format(Locale.US, nanos.toLong()) 148 } else { 149 // 13 + 2(.X) to match alignment above 150 "%,15.1f ns".format(Locale.US, nanos) 151 } 152 if (allocations != null) { 153 // 9 alignment is enough for ~10 million allocations 154 output += " %8d allocs".format(Locale.US, allocations.toInt()) 155 } 156 profilerResults.forEach { 157 output += " ${createFileLink(it.label, it.outputRelativePath)}" 158 } 159 output += " $benchmarkName" 160 return output 161 } 162 163 private var ideWarningPrefix = "" 164 165 @TestOnly clearIdeWarningPrefixnull166 fun clearIdeWarningPrefix() { 167 println("clear ide warning") 168 ideWarningPrefix = "" 169 } 170 171 /** 172 * Schedule a string to be reported to the IDE on next benchmark report. 173 * 174 * Requires ideSummary to be called afterward, since we only post one instrumentation result per 175 * test. 176 * 177 * Note that this also prints to logcat. 178 */ scheduleIdeWarningOnNextReportnull179 fun scheduleIdeWarningOnNextReport(string: String) { 180 ideWarningPrefix = 181 if (ideWarningPrefix.isEmpty()) { 182 string 183 } else { 184 ideWarningPrefix + "\n" + string 185 } 186 string.split("\n").map { Log.w(BenchmarkState.TAG, it) } 187 } 188 ideSummarynull189 internal fun ideSummary( 190 testName: String? = null, 191 message: String? = null, 192 measurements: Measurements? = null, 193 iterationTracePaths: List<String>? = null, 194 profilerResults: List<Profiler.ResultFile> = emptyList(), 195 insightSummaries: List<InsightSummary> = emptyList(), 196 useTreeDisplayFormat: Boolean = false, 197 ): IdeSummaryPair { 198 val warningMessage = ideWarningPrefix.ifEmpty { null } 199 ideWarningPrefix = "" 200 201 val v2metricLines: List<String> 202 val linkableIterTraces = 203 iterationTracePaths?.map { absolutePath -> Outputs.relativePathFor(absolutePath) } 204 ?: emptyList() 205 206 if (measurements != null) { 207 require(measurements.isNotEmpty()) { "Require non-empty list of metric results." } 208 val setOfMetrics = measurements.singleMetrics.map { it.name }.toSet() 209 // specialized single line codepath for microbenchmarks with only 2 default metrics 210 if ( 211 iterationTracePaths == null && 212 testName != null && 213 message == null && 214 measurements.sampledMetrics.isEmpty() && 215 (setOfMetrics == setOf("timeNs", "allocationCount") || 216 setOfMetrics == setOf("timeNs")) 217 ) { 218 val nanos = measurements.singleMetrics.single { it.name == "timeNs" }.min 219 val allocs = 220 measurements.singleMetrics.singleOrNull { it.name == "allocationCount" }?.min 221 // add newline (note that multi-line codepath below handles newline separately) 222 val warningPrefix = if (warningMessage == null) "" else warningMessage + "\n" 223 return IdeSummaryPair( 224 summaryV2 = 225 warningPrefix + 226 ideSummaryBasicMicro(testName, nanos, allocs, profilerResults) 227 ) 228 } 229 230 val allMetrics = measurements.singleMetrics + measurements.sampledMetrics 231 val maxLabelLength = allMetrics.maxOf { it.name.length } 232 fun Double.toDisplayString() = "%,.1f".format(Locale.US, this) 233 234 // max string length of any printed min/med/max is the largest max value seen. used to 235 // pad. 236 val maxValueLength = allMetrics.maxOf { it.max }.toDisplayString().length 237 238 fun metricLines( 239 singleTransform: 240 ( 241 name: String, 242 min: String, 243 median: String, 244 max: String, 245 metricResult: MetricResult 246 ) -> String 247 ) = 248 measurements.singleMetrics.map { 249 singleTransform( 250 it.name.padEnd(maxLabelLength), 251 it.min.toDisplayString().padStart(maxValueLength), 252 it.median.toDisplayString().padStart(maxValueLength), 253 it.max.toDisplayString().padStart(maxValueLength), 254 it 255 ) 256 } + 257 measurements.sampledMetrics.map { 258 val name = it.name.padEnd(maxLabelLength) 259 val p50 = it.p50.toDisplayString().padStart(maxValueLength) 260 val p90 = it.p90.toDisplayString().padStart(maxValueLength) 261 val p95 = it.p95.toDisplayString().padStart(maxValueLength) 262 val p99 = it.p99.toDisplayString().padStart(maxValueLength) 263 // we don't try and link percentiles, since they're grouped across multiple 264 // iters 265 " $name P50 $p50, P90 $p90, P95 $p95, P99 $p99" 266 } 267 268 v2metricLines = 269 if (linkableIterTraces.isNotEmpty()) { 270 // Per iteration trace paths present, so link min/med/max to respective 271 // iteration traces 272 metricLines { name, min, median, max, result -> 273 " $name" + 274 " ${createFileLink("min $min", linkableIterTraces[result.minIndex])}," + 275 " ${createFileLink("median $median", linkableIterTraces[result.medianIndex])}," + 276 " ${createFileLink("max $max", linkableIterTraces[result.maxIndex])}" 277 } 278 } else { 279 // No iteration traces, so just basic list 280 metricLines { name, min, median, max, _ -> 281 " $name min $min, median $median, max $max" 282 } 283 } 284 } else { 285 // no metrics to report 286 v2metricLines = emptyList() 287 } 288 289 if (!useTreeDisplayFormat) { // use the regular output format 290 val v2traceLinks = 291 if (linkableIterTraces.isNotEmpty()) { 292 listOf( 293 " Traces: Iteration " + 294 linkableIterTraces 295 .mapIndexed { index, path -> createFileLink("$index", path) } 296 .joinToString(" ") 297 ) 298 } else { 299 emptyList() 300 } + profilerResults.map { " ${createFileLink(it.label, it.outputRelativePath)}" } 301 return IdeSummaryPair( 302 v2lines = 303 listOfNotNull(warningMessage, testName, message) + 304 v2metricLines + 305 v2traceLinks + 306 "" /* adds \n */ 307 ) 308 } else { // use the experimental tree-like output format 309 val formatLines = 310 LinkFormat.entries.associateWith { linkFormat -> 311 buildList { 312 if (warningMessage != null) add(warningMessage) 313 if (testName != null) add(testName) 314 if (message != null) add(message) 315 val tree = TreeBuilder() 316 if (v2metricLines.isNotEmpty()) { 317 tree.append("Metrics", 0) 318 for (metric in v2metricLines) tree.append(metric, 1) 319 } 320 if (insightSummaries.isNotEmpty()) { 321 tree.append("App Startup Insights", 0) 322 for (insight in insightSummaries) { 323 tree.append(insight.category, 1) 324 val observed = 325 when (linkFormat) { 326 LinkFormat.V2 -> insight.observedV2 327 LinkFormat.V3 -> insight.observedV3 328 } 329 tree.append(observed, 2) 330 } 331 } 332 if (linkableIterTraces.isNotEmpty() || profilerResults.isNotEmpty()) { 333 tree.append("Traces", 0) 334 if (linkableIterTraces.isNotEmpty()) 335 tree.append( 336 linkableIterTraces 337 .mapIndexed { ix, trace -> createFileLink("$ix", trace) } 338 .joinToString(prefix = "Iteration ", separator = " "), 339 1 340 ) 341 for (line in profilerResults) tree.append( 342 createFileLink(line.label, line.outputRelativePath), 343 1 344 ) 345 } 346 addAll(tree.build()) 347 add("") 348 } 349 } 350 return IdeSummaryPair( 351 v2lines = formatLines[LinkFormat.V2]!!, 352 v3lines = formatLines[LinkFormat.V3]!! 353 ) 354 } 355 } 356 357 /** 358 * Report an output file for test infra to copy. 359 * 360 * [reportOnRunEndOnly] `=true` should only be used for files that aggregate data across many 361 * tests, such as the final report json. All other files should be unique, per test. 362 * 363 * In internal terms, per-test results are called "test metrics", and per-run results are called 364 * "run metrics". A profiling trace of a particular method would be a test metric, the full 365 * output json would be a run metric. 366 * 367 * In am instrument terms, per-test results are printed with `INSTRUMENTATION_STATUS:`, and 368 * per-run results are reported with `INSTRUMENTATION_RESULT:`. 369 */ 370 @Suppress("MissingJvmstatic") reportAdditionalFileToCopynull371 public fun reportAdditionalFileToCopy( 372 key: String, 373 absoluteFilePath: String, 374 reportOnRunEndOnly: Boolean = false 375 ) { 376 require(!key.contains('=')) { 377 "Key must not contain '=', which breaks instrumentation result string parsing" 378 } 379 if (reportOnRunEndOnly) { 380 InstrumentationResultScope(runEndResultBundle).fileRecord(key, absoluteFilePath) 381 } else { 382 instrumentationReport { fileRecord(key, absoluteFilePath) } 383 } 384 } 385 386 /** 387 * Report results bundle to instrumentation 388 * 389 * Before addResults() was added in the platform, we use sendStatus(). The constant '2' comes 390 * from IInstrumentationResultParser.StatusCodes.IN_PROGRESS, and signals the test infra that 391 * this is an "additional result" bundle, equivalent to addResults() NOTE: we should a version 392 * check to call addResults(), but don't yet due to b/155103514 393 * 394 * @param bundle The [Bundle] to be reported to [android.app.Instrumentation] 395 */ reportBundlenull396 internal fun reportBundle(bundle: Bundle) { 397 InstrumentationRegistry.getInstrumentation().sendStatus(2, bundle) 398 } 399 } 400 401 /** 402 * Constructs a hierarchical, tree-like representation of data, similar to the output of the 'tree' 403 * command. 404 */ 405 private class TreeBuilder { 406 private val lines = mutableListOf<StringBuilder>() 407 private val nbsp = '\u00A0' 408 appendnull409 fun append(message: String, depth: Int): TreeBuilder { 410 require(depth >= 0) 411 412 // Create a new line for the tree node, with appropriate indentation using spaces. 413 val line = StringBuilder() 414 repeat(depth * 4) { line.append(nbsp) } 415 line.append("└── ") 416 line.append(message) 417 lines.add(line) 418 419 // Update vertical lines (pipes) to visually connect the new node to its parent/sibling. 420 // TODO: Optimize this for deep trees to avoid potential quadratic time complexity. 421 val anchorColumn = depth * 4 422 var i = lines.lastIndex - 1 // start climbing with the first line above the newly added one 423 while (i >= 0 && lines[i].getOrNull(anchorColumn) == nbsp) lines[i--][anchorColumn] = '│' 424 if (i >= 0 && lines[i].getOrNull(anchorColumn) == '└') lines[i][anchorColumn] = '├' 425 426 return this 427 } 428 <lambda>null429 fun build(): List<String> = lines.map { it.toString() } 430 } 431