001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.benchmarking.common.converter; 017 018import com.google.gson.Gson; 019import com.google.gson.JsonArray; 020import com.google.gson.JsonElement; 021import com.google.gson.JsonObject; 022import de.cuioss.benchmarking.common.config.BenchmarkType; 023import de.cuioss.benchmarking.common.model.BenchmarkData; 024import de.cuioss.benchmarking.common.report.MetricConversionUtil; 025 026import java.io.IOException; 027import java.nio.file.Files; 028import java.nio.file.Path; 029import java.time.Instant; 030import java.time.ZoneOffset; 031import java.time.format.DateTimeFormatter; 032import java.util.*; 033 034/** 035 * Converts JMH benchmark results to the central BenchmarkData model 036 */ 037public class JmhBenchmarkConverter implements BenchmarkConverter { 038 039 private static final Gson GSON = new Gson(); 040 public static final String THRPT = "thrpt"; 041 042 private final BenchmarkType benchmarkType; 043 private final String configuredThroughputName; 044 private final String configuredLatencyName; 045 private final String projectName; 046 047 public JmhBenchmarkConverter(BenchmarkType benchmarkType) { 048 this(benchmarkType, null, null, null); 049 } 050 051 public JmhBenchmarkConverter(BenchmarkType benchmarkType, 052 String configuredThroughputName, String configuredLatencyName) { 053 this(benchmarkType, configuredThroughputName, configuredLatencyName, null); 054 } 055 056 public JmhBenchmarkConverter(BenchmarkType benchmarkType, 057 String configuredThroughputName, String configuredLatencyName, String projectName) { 058 this.benchmarkType = benchmarkType; 059 this.configuredThroughputName = configuredThroughputName; 060 this.configuredLatencyName = configuredLatencyName; 061 this.projectName = projectName; 062 } 063 064 @Override 065 public BenchmarkData convert(Path sourcePath) throws IOException { 066 String json = Files.readString(sourcePath); 067 JsonArray jmhResults = GSON.fromJson(json, JsonArray.class); 068 069 List<BenchmarkData.Benchmark> benchmarks = new ArrayList<>(); 070 071 for (JsonElement element : jmhResults) { 072 JsonObject jmhBenchmark = element.getAsJsonObject(); 073 benchmarks.add(convertJmhBenchmark(jmhBenchmark)); 074 } 075 076 return BenchmarkData.builder() 077 .metadata(createMetadata()) 078 .overview(createOverview(benchmarks)) 079 .benchmarks(benchmarks) 080 .build(); 081 } 082 083 @Override 084 public boolean canConvert(Path sourcePath) { 085 return sourcePath.getFileName().toString().endsWith(".json") && 086 (sourcePath.getFileName().toString().contains("jmh") || 087 sourcePath.getFileName().toString().contains("result")); 088 } 089 090 private BenchmarkData.Benchmark convertJmhBenchmark(JsonObject jmh) { 091 String name = jmh.get("benchmark").getAsString(); 092 String mode = jmh.get("mode").getAsString(); 093 094 JsonObject primaryMetric = jmh.getAsJsonObject("primaryMetric"); 095 double score = primaryMetric.get("score").getAsDouble(); 096 String scoreUnit = primaryMetric.get("scoreUnit").getAsString(); 097 098 // Convert units for better readability 099 double convertedScore = score; 100 String convertedUnit = scoreUnit; 101 102 // Convert ops/ms to ops/s for throughput benchmarks 103 if (THRPT.equals(mode) && "ops/ms".equals(scoreUnit)) { 104 convertedScore = score * 1000; // Convert ops/ms to ops/s 105 convertedUnit = "ops/s"; 106 } 107 108 Map<String, Double> percentiles = new LinkedHashMap<>(); 109 if (primaryMetric.has("scorePercentiles")) { 110 JsonObject scorePercentiles = primaryMetric.getAsJsonObject("scorePercentiles"); 111 for (Map.Entry<String, JsonElement> entry : scorePercentiles.entrySet()) { 112 double percentileValue = entry.getValue().getAsDouble(); 113 // Apply same unit conversions to percentiles 114 if (THRPT.equals(mode) && "ops/ms".equals(scoreUnit)) { 115 percentileValue = percentileValue * 1000; 116 } else { 117 // Convert latency percentiles to milliseconds 118 percentileValue = switch (scoreUnit) { 119 case "us/op" -> percentileValue / 1000.0; 120 case "ns/op" -> percentileValue / 1_000_000.0; 121 case "s/op" -> percentileValue * 1000.0; 122 default -> percentileValue; 123 }; 124 } 125 percentiles.put(entry.getKey(), percentileValue); 126 } 127 } 128 129 return BenchmarkData.Benchmark.builder() 130 .name(extractSimpleName(name)) 131 .fullName(name) 132 .mode(mode) 133 .rawScore(convertedScore) 134 .score(formatScore(convertedScore, convertedUnit)) 135 .scoreUnit(convertedUnit) 136 .throughput(THRPT.equals(mode) ? formatScore(convertedScore, convertedUnit) : null) 137 .latency("avgt".equals(mode) || "sample".equals(mode) ? formatScore(score, scoreUnit) : null) 138 .error(primaryMetric.has("scoreError") ? primaryMetric.get("scoreError").getAsDouble() : 0.0) 139 .percentiles(percentiles) 140 .build(); 141 } 142 143 private BenchmarkData.Metadata createMetadata() { 144 Instant now = Instant.now(); 145 return BenchmarkData.Metadata.builder() 146 .timestamp(now.toString()) 147 .displayTimestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'") 148 .withZone(ZoneOffset.UTC) 149 .format(now)) 150 .benchmarkType(benchmarkType.getDisplayName()) 151 .reportVersion("2.0") 152 .projectName(projectName) 153 .build(); 154 } 155 156 private BenchmarkData.Overview createOverview(List<BenchmarkData.Benchmark> benchmarks) { 157 // Find throughput benchmark: prefer configured name, fall back to max-score heuristic 158 Optional<BenchmarkData.Benchmark> bestThroughput = findByConfiguredName( 159 benchmarks, configuredThroughputName, THRPT) 160 .or(() -> benchmarks.stream() 161 .filter(b -> THRPT.equals(b.getMode())) 162 .max(Comparator.comparing(BenchmarkData.Benchmark::getRawScore))); 163 164 // Find latency benchmark: prefer configured name, fall back to min-score heuristic 165 Optional<BenchmarkData.Benchmark> bestLatency = findByConfiguredName( 166 benchmarks, configuredLatencyName, "avgt") 167 .or(() -> benchmarks.stream() 168 .filter(b -> "avgt".equals(b.getMode()) || "sample".equals(b.getMode())) 169 .min(Comparator.comparing(BenchmarkData.Benchmark::getRawScore))); 170 171 double throughput = bestThroughput.map(BenchmarkData.Benchmark::getRawScore).orElse(0.0); 172 // IMPORTANT: Convert latency to milliseconds using the benchmark's unit 173 // The rawScore is in the original unit (us/op, ms/op, etc.) 174 // We need to convert to milliseconds per operation for consistent reporting 175 double latency = bestLatency.map(b -> { 176 double rawLatency = b.getRawScore(); 177 String unit = b.getScoreUnit(); 178 // Convert from various time units to milliseconds 179 return switch (unit) { 180 case "us/op" -> rawLatency / 1000.0; // microseconds to milliseconds 181 case "ns/op" -> rawLatency / 1_000_000.0; // nanoseconds to milliseconds 182 case "s/op" -> rawLatency * 1000.0; // seconds to milliseconds 183 default -> rawLatency; // "ms/op" or unknown - assume already in milliseconds 184 }; 185 }).orElse(0.0); 186 187 int score = calculatePerformanceScore(throughput, latency); 188 String grade = calculatePerformanceGrade(score); 189 190 return BenchmarkData.Overview.builder() 191 .throughput(bestThroughput.map(BenchmarkData.Benchmark::getScore).orElse("N/A")) 192 .latency(latency > 0 ? MetricConversionUtil.formatLatency(latency) : "N/A") 193 .throughputOpsPerSec(throughput) // Store numeric value used for score calculation 194 .latencyMs(latency) // Store numeric value used for score calculation 195 .throughputBenchmarkName(bestThroughput.map(BenchmarkData.Benchmark::getName).orElse("")) 196 .latencyBenchmarkName(bestLatency.map(BenchmarkData.Benchmark::getName).orElse("")) 197 .performanceScore(score) 198 .performanceGrade(grade) 199 .performanceGradeClass("grade-" + grade.toLowerCase()) 200 .build(); 201 } 202 203 private static Optional<BenchmarkData.Benchmark> findByConfiguredName( 204 List<BenchmarkData.Benchmark> benchmarks, String configuredName, String expectedMode) { 205 if (configuredName == null || configuredName.isBlank()) { 206 return Optional.empty(); 207 } 208 return benchmarks.stream() 209 .filter(b -> expectedMode.equals(b.getMode())) 210 .filter(b -> b.getName().equals(configuredName) || b.getFullName().equals(configuredName)) 211 .findFirst(); 212 } 213 214 private String extractSimpleName(String fullName) { 215 int lastDot = fullName.lastIndexOf('.'); 216 return lastDot >= 0 ? fullName.substring(lastDot + 1) : fullName; 217 } 218 219 private String formatScore(double score, String unit) { 220 if (score >= 1000) { 221 return String.format(Locale.US, "%.1fK %s", score / 1000, unit); 222 } 223 return String.format(Locale.US, "%.1f %s", score, unit); 224 } 225 226 private int calculatePerformanceScore(double throughput, double latency) { 227 // Performance scoring per benchmarking/doc/performance-scoring.adoc 228 // Performance Score = (Throughput_Score × 0.5) + (Latency_Score × 0.5) 229 // Where: 230 // - Throughput_Score = Throughput ÷ 100 231 // - Latency_Score = 100 ÷ Latency_ms 232 // Scores are NOT capped - exceptional performance can exceed 100 233 double throughputScore = throughput / 100.0; 234 double latencyScore = latency > 0 ? 100.0 / latency : 0.0; 235 double rawScore = (throughputScore * 0.5) + (latencyScore * 0.5); 236 return (int) Math.round(rawScore); 237 } 238 239 private String calculatePerformanceGrade(int score) { 240 if (score >= 95) return "A+"; 241 if (score >= 90) return "A"; 242 if (score >= 75) return "B"; 243 if (score >= 60) return "C"; 244 if (score >= 40) return "D"; 245 return "F"; 246 } 247}