1 /* 2 * Copyright (C) 2021 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 package com.android.tradefed.util; 17 18 import com.android.annotations.VisibleForTesting; 19 import com.android.tradefed.log.LogUtil.CLog; 20 import com.android.tradefed.metrics.proto.MetricMeasurement.Measurements; 21 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric; 22 import com.android.tradefed.result.TestDescription; 23 import com.android.tradefed.util.proto.TfMetricProtoUtil; 24 25 import com.google.common.base.Joiner; 26 import com.google.common.collect.ArrayListMultimap; 27 import com.google.common.math.Quantiles; 28 29 import java.io.File; 30 import java.io.FileOutputStream; 31 import java.io.IOException; 32 import java.util.Arrays; 33 import java.util.ArrayList; 34 import java.util.Collection; 35 import java.util.Collections; 36 import java.util.HashMap; 37 import java.util.LinkedHashMap; 38 import java.util.LinkedHashSet; 39 import java.util.List; 40 import java.util.Map; 41 import java.util.Map.Entry; 42 import java.util.regex.Pattern; 43 import java.util.Set; 44 import java.util.stream.Collectors; 45 46 /** 47 * Contains common utility methods for storing the test metrics, aggregating the metrics in similar 48 * tests and writing the metrics to a file. 49 */ 50 public class MetricUtility { 51 52 private static final String TEST_HEADER_SEPARATOR = "\n\n"; 53 private static final String METRIC_SEPARATOR = "\n"; 54 private static final String METRIC_KEY_VALUE_SEPARATOR = ":"; 55 private static final String STATS_KEY_MIN = "min"; 56 private static final String STATS_KEY_MAX = "max"; 57 private static final String STATS_KEY_MEAN = "mean"; 58 private static final String STATS_KEY_VAR = "var"; 59 private static final String STATS_KEY_STDEV = "stdev"; 60 private static final String STATS_KEY_MEDIAN = "median"; 61 private static final String STATS_KEY_TOTAL = "total"; 62 private static final String STATS_KEY_COUNT = "metric-count"; 63 private static final String STATS_KEY_DIFF = "last-first-diff"; 64 private static final String STATS_KEY_PERCENTILE_PREFIX = "p"; 65 private static final String STATS_KEY_SEPARATOR = "-"; 66 private static final Joiner CLASS_METHOD_JOINER = Joiner.on("#").skipNulls(); 67 68 // Used to separate the package name from the iteration number. Default is set to "$". 69 private String mTestIterationSeparator = "$"; 70 71 // Percentiles to include when calculating the aggregates. 72 private Set<Integer> mActualPercentiles = new LinkedHashSet<>(); 73 74 // Store the test metrics for aggregation at the end of test run. 75 // Outer map key is the test id and inner map key is the metric key name. 76 private Map<String, ArrayListMultimap<String, Metric>> mStoredTestMetrics = 77 new LinkedHashMap<String, ArrayListMultimap<String, Metric>>(); 78 79 private List<Pattern> mMetricPatterns = new ArrayList<>(); 80 81 /** 82 * Used for storing the individual test metrics and use it for aggregation. 83 * 84 * @param testDescription contains the test details like class name and test name. 85 * @param testMetrics metrics collected for the test. 86 */ storeTestMetrics(TestDescription testDescription, Map<String, Metric> testMetrics)87 public void storeTestMetrics(TestDescription testDescription, 88 Map<String, Metric> testMetrics) { 89 90 if (testMetrics == null) { 91 return; 92 } 93 94 // Group test cases which differs only by the iteration separator or test the same name. 95 // Removes iteration numbers like "$17". 96 String newTestId = 97 CLASS_METHOD_JOINER 98 .join(testDescription.getClassName(), testDescription.getTestName()) 99 .replaceFirst(Pattern.quote(mTestIterationSeparator) + "\\d+", ""); 100 101 if (!mStoredTestMetrics.containsKey(newTestId)) { 102 mStoredTestMetrics.put(newTestId, ArrayListMultimap.create()); 103 } 104 ArrayListMultimap<String, Metric> storedMetricsForThisTest = mStoredTestMetrics 105 .get(newTestId); 106 for (Map.Entry<String, Metric> entry : testMetrics.entrySet()) { 107 storedMetricsForThisTest.put(entry.getKey(), entry.getValue()); 108 } 109 } 110 111 /** 112 * 113 * Write metrics to a file. 114 * 115 * @param testFileSuffix is used as suffix in the test metric file name. 116 * @param testHeaderName metrics will be written under the test header name. 117 * @param metrics to write in the file. 118 * @param resultsFile if null create a new file and write the metrics otherwise append the 119 * test header name and metric to the file. 120 * @return file with the metric. 121 */ writeResultsToFile(String testFileSuffix, String testHeaderName, Map<String, String> metrics, File resultsFile)122 public File writeResultsToFile(String testFileSuffix, String testHeaderName, 123 Map<String, String> metrics, File resultsFile) { 124 125 if (resultsFile == null) { 126 try { 127 resultsFile = FileUtil.createTempFile(String.format("test_results_%s_", 128 testFileSuffix), ""); 129 } catch (IOException e) { 130 CLog.e(e); 131 return resultsFile; 132 } 133 } 134 135 try (FileOutputStream outputStream = new FileOutputStream(resultsFile, true)) { 136 // Write the header description name. 137 outputStream.write(String.format("%s%s", testHeaderName, TEST_HEADER_SEPARATOR) 138 .getBytes()); 139 for (Map.Entry<String, String> entry : metrics.entrySet()) { 140 String test_metric = String.format("%s%s%s", entry.getKey(), 141 METRIC_KEY_VALUE_SEPARATOR, entry.getValue()); 142 outputStream.write(String.format("%s%s", test_metric, METRIC_SEPARATOR).getBytes()); 143 } 144 outputStream.write(TEST_HEADER_SEPARATOR.getBytes()); 145 } catch (IOException ioe) { 146 CLog.e(ioe); 147 } 148 return resultsFile; 149 } 150 151 /** 152 * Aggregate comma separated metrics. 153 * 154 * @param rawMetrics metrics collected during the test run. 155 * @return aggregated metrics. 156 */ aggregateMetrics(Map<String, Metric> rawMetrics)157 public Map<String, Metric> aggregateMetrics(Map<String, Metric> rawMetrics) { 158 Map<String, Metric> aggregateMetrics = new LinkedHashMap<String, Metric>(); 159 for (Map.Entry<String, Metric> entry : rawMetrics.entrySet()) { 160 String values = entry.getValue().getMeasurements().getSingleString(); 161 List<String> splitVals = Arrays.asList(values.split(",", 0)); 162 // Build stats for keys with any values, even only one. 163 if (isAllDoubleValues(splitVals)) { 164 buildStats(entry.getKey(), splitVals, aggregateMetrics); 165 } 166 } 167 return aggregateMetrics; 168 } 169 170 /** 171 * Aggregate the metrics collected from multiple iterations of the test and 172 * write the aggregated metrics to a test result file. 173 * 174 * @param fileName file name to use while writing the metrics. 175 */ aggregateStoredTestMetricsAndWriteToFile(String fileName)176 public File aggregateStoredTestMetricsAndWriteToFile(String fileName) { 177 File resultsFile = null; 178 for (String testName : mStoredTestMetrics.keySet()) { 179 ArrayListMultimap<String, Metric> currentTest = mStoredTestMetrics.get(testName); 180 181 Map<String, Metric> aggregateMetrics = new LinkedHashMap<String, Metric>(); 182 for (String metricKey : currentTest.keySet()) { 183 List<Metric> metrics = currentTest.get(metricKey); 184 List<Measurements> measures = metrics.stream().map(Metric::getMeasurements) 185 .collect(Collectors.toList()); 186 // Parse metrics into a list of SingleString values, concating lists in the process 187 List<String> rawValues = measures.stream() 188 .map(Measurements::getSingleString) 189 .map( 190 m -> { 191 // Split results; also deals with the case of empty results 192 // in a certain run 193 List<String> splitVals = Arrays.asList(m.split(",", 0)); 194 if (splitVals.size() == 1 && splitVals.get(0).isEmpty()) { 195 return Collections.<String> emptyList(); 196 } 197 return splitVals; 198 }) 199 .flatMap(Collection::stream) 200 .map(String::trim) 201 .collect(Collectors.toList()); 202 // Do not report empty metrics 203 if (rawValues.isEmpty()) { 204 continue; 205 } 206 if (isAllDoubleValues(rawValues)) { 207 buildStats(metricKey, rawValues, aggregateMetrics); 208 } 209 } 210 211 aggregateMetrics = 212 mMetricPatterns.size() > 0 ? filterMetrics(aggregateMetrics) : aggregateMetrics; 213 Map<String, String> compatibleTestMetrics = TfMetricProtoUtil 214 .compatibleConvert(aggregateMetrics); 215 216 resultsFile = writeResultsToFile(fileName, testName, 217 compatibleTestMetrics, resultsFile); 218 } 219 return resultsFile; 220 } 221 setPercentiles(Set<Integer> percentiles)222 public void setPercentiles(Set<Integer> percentiles) { 223 mActualPercentiles = percentiles; 224 } 225 setIterationSeparator(String separator)226 public void setIterationSeparator(String separator) { 227 mTestIterationSeparator = separator; 228 } 229 230 @VisibleForTesting getStoredTestMetric()231 public Map<String, ArrayListMultimap<String, Metric>> getStoredTestMetric() { 232 return mStoredTestMetrics; 233 } 234 235 /** 236 * Return true is all the values can be parsed to double value. 237 * Otherwise return false. 238 * 239 * @param rawValues list whose values are validated. 240 */ isAllDoubleValues(List<String> rawValues)241 public static boolean isAllDoubleValues(List<String> rawValues) { 242 return rawValues 243 .stream() 244 .allMatch( 245 val -> { 246 try { 247 Double.parseDouble(val); 248 return true; 249 } catch (NumberFormatException e) { 250 return false; 251 } 252 }); 253 } 254 255 /** 256 * Compute the stats from the give list of values. 257 * 258 * @param values raw values to compute the aggregation. 259 * @param percentiles stats to include in the final metrics. 260 * @return aggregated values. 261 */ 262 public static Map<String, Double> getStats(List<Double> values, 263 Set<Integer> percentiles) { 264 Map<String, Double> stats = new LinkedHashMap<>(); 265 double sum = values.stream().mapToDouble(Double::doubleValue).sum(); 266 double count = values.size(); 267 // The orElse situation should never happen. 268 double mean = values.stream() 269 .mapToDouble(Double::doubleValue) 270 .average() 271 .orElseThrow(IllegalStateException::new); 272 double variance = values.stream().reduce(0.0, (a, b) -> a + Math.pow(b - mean, 2) / count); 273 // Calculate percentiles. 50 th percentile will be used as median. 274 Set<Integer> updatedPercentile = new LinkedHashSet<>(percentiles); 275 updatedPercentile.add(50); 276 Map<Integer, Double> percentileStat = Quantiles.percentiles().indexes(updatedPercentile) 277 .compute(values); 278 double median = percentileStat.get(50); 279 // Diff of last and first value from the list. Return the first value if only one value is 280 // present. 281 double diff = (values.size() > 1) ? (values.get(values.size() - 1) - values.get(0)) 282 : values.get(0); 283 284 stats.put(STATS_KEY_MIN, Collections.min(values)); 285 stats.put(STATS_KEY_MAX, Collections.max(values)); 286 stats.put(STATS_KEY_MEAN, mean); 287 stats.put(STATS_KEY_VAR, variance); 288 stats.put(STATS_KEY_STDEV, Math.sqrt(variance)); 289 stats.put(STATS_KEY_MEDIAN, median); 290 stats.put(STATS_KEY_TOTAL, sum); 291 stats.put(STATS_KEY_COUNT, count); 292 stats.put(STATS_KEY_DIFF, diff); 293 percentileStat 294 .entrySet() 295 .stream() 296 .forEach( 297 e -> { 298 // If the percentile is 50, only include it if the user asks for it 299 // explicitly. 300 if (e.getKey() != 50 || percentiles.contains(50)) { 301 stats.put( 302 STATS_KEY_PERCENTILE_PREFIX + e.getKey().toString(), 303 e.getValue()); 304 } 305 }); 306 return stats; 307 } 308 309 /** 310 * Build stats for the given set of values and build the metrics using the metric key 311 * and stats name and update the results in aggregated metrics. 312 * 313 * @param metricKey key to which the values correspond to. 314 * @param values list of raw values. 315 * @param aggregateMetrics where final metrics will be stored. 316 */ 317 private void buildStats(String metricKey, List<String> values, 318 Map<String, Metric> aggregateMetrics) { 319 List<Double> doubleValues = values.stream().map(Double::parseDouble) 320 .collect(Collectors.toList()); 321 Map<String, Double> stats = getStats(doubleValues, mActualPercentiles); 322 for (String statKey : stats.keySet()) { 323 Metric.Builder metricBuilder = Metric.newBuilder(); 324 metricBuilder 325 .getMeasurementsBuilder() 326 .setSingleString(String.format("%2.2f", stats.get(statKey))); 327 aggregateMetrics.put( 328 String.join(STATS_KEY_SEPARATOR, metricKey, statKey), 329 metricBuilder.build()); 330 } 331 } 332 333 /** Build regular expression patterns to filter the metrics. */ 334 public void buildMetricFilterPatterns(Set<String> strictIncludeRegEx) { 335 if (!strictIncludeRegEx.isEmpty() && mMetricPatterns.isEmpty()) { 336 for (String regEx : strictIncludeRegEx) { 337 mMetricPatterns.add(Pattern.compile(regEx)); 338 } 339 } 340 } 341 342 /** Filter the metrics that matches the pattern. */ 343 public Map<String, Metric> filterMetrics(Map<String, Metric> parsedMetrics) { 344 Map<String, Metric> filteredMetrics = new HashMap<>(); 345 for (Entry<String, Metric> metricEntry : parsedMetrics.entrySet()) { 346 for (Pattern pattern : mMetricPatterns) { 347 if (pattern.matcher(metricEntry.getKey()).matches()) { 348 filteredMetrics.put(metricEntry.getKey(), metricEntry.getValue()); 349 break; 350 } 351 } 352 } 353 return filteredMetrics; 354 } 355 } 356