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