blob: 29bb27fe94e22b95f907eee536bed0e558975977 [file] [log] [blame]
package convert
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"time"
)
var (
unitMap = map[string]string{
"nanoseconds": "ms",
"ns": "ms",
"milliseconds": "ms",
"bytes": "sizeInBytes",
"frames/second": "Hz",
"percent": "n%",
"bytes/second": "unitless", // Was bytesPerSecond in C++ map key
"bits/second": "unitless",
// Units that map to themselves
"ms": "ms",
"msBestFitFormat": "msBestFitFormat",
"tsMs": "tsMs",
"n%": "n%",
"sizeInBytes": "sizeInBytes",
"J": "J",
"W": "W",
"A": "A",
"Ah": "Ah",
"V": "V",
"Hz": "Hz",
"unitless": "unitless",
"count": "count",
"sigma": "sigma",
}
defaultImprovementDirection = map[string]string{
"unitless": "biggerIsBetter", // covers bytes/second, bits/second
"sizeInBytes": "smallerIsBetter",
"J": "smallerIsBetter",
"W": "smallerIsBetter",
"A": "smallerIsBetter",
"V": "smallerIsBetter",
"Hz": "biggerIsBetter", // covers frames/second
"sigma": "smallerIsBetter",
"n%": "smallerIsBetter", // covers percent
"ms": "smallerIsBetter", // covers nanoseconds, milliseconds
"count": "smallerIsBetter",
// msBestFitFormat, tsMs, Ah not in the C++ defaults, will default to smallerIsBetter
}
)
// stdDev calculates the standard deviation of a slice of float64.
func stdDev(values []float64) float64 {
if len(values) <= 1 {
return 0.0
}
mean := 0.0
for _, v := range values {
mean += v
}
mean /= float64(len(values))
variance := 0.0
for _, v := range values {
variance += math.Pow(v-mean, 2)
}
variance /= float64(len(values) - 1) // Sample standard deviation
return math.Sqrt(variance)
}
// Run performs the JSON conversion.
func Run(cfg Config) error {
ctx := context.Background()
if cfg.Master == "" {
return fmt.Errorf("master is required")
}
fmt.Printf("Input file: %s\n", cfg.InputFile)
fmt.Printf("Output directory: %s\n", cfg.OutputDir)
fmt.Printf("Master: %s\n", cfg.Master)
gcsPathPrefix := ""
if cfg.Date != "" {
t, err := time.Parse("2006-01-02", cfg.Date)
if err != nil {
return fmt.Errorf("invalid date format: %w", err) // Should have been caught in main
}
gcsPathPrefix = filepath.Join("ingest", t.Format("2006/01/02"))
fmt.Printf("GCS Path Prefix: %s\n", gcsPathPrefix)
}
// Read the input file
inputData, err := os.ReadFile(cfg.InputFile)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
}
// Unmarshal the JSON data
var fuchsiaResults FuchsiaPerfResults
if err := json.Unmarshal(inputData, &fuchsiaResults); err != nil {
return fmt.Errorf("failed to unmarshal JSON: %w", err)
}
fmt.Printf("Successfully unmarshaled %d records\n", len(fuchsiaResults))
// Validate the unmarshaled data
for i, result := range fuchsiaResults {
if result.BuildID == "" {
return fmt.Errorf("record %d: build_id is empty", i)
}
if result.Builder == "" {
return fmt.Errorf("record %d: builder is empty", i)
}
if result.CommitID == "" {
return fmt.Errorf("record %d: commit_id is empty", i)
}
if result.PerfResults == nil || len(result.PerfResults) == 0 {
return fmt.Errorf("record %d: perf_results is empty or null", i)
}
for j, perf := range result.PerfResults {
if perf.TestSuite == "" {
return fmt.Errorf("record %d, perf_result %d: test_suite is empty", i, j)
}
if perf.TestName == "" {
return fmt.Errorf("record %d, perf_result %d: test_name is empty", i, j)
}
if perf.Unit == "" {
return fmt.Errorf("record %d, perf_result %d: unit is empty", i, j)
}
}
}
// Prepare output directory if provided
if cfg.OutputDir != "" {
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
fmt.Printf("Output directory: %s\n", cfg.OutputDir)
}
// Process each top-level record (build) separately
for _, record := range fuchsiaResults {
// Group results within this record by test_suite
benchmarks := make(map[string][]FuchsiaPerfResultItem)
for _, perf := range record.PerfResults {
benchmarks[perf.TestSuite] = append(benchmarks[perf.TestSuite], perf)
}
// Create an output file for each benchmark in this record
for benchmark, results := range benchmarks {
skiaResult := SkiaPerfResult{
Version: 1,
GitHash: record.CommitID,
Key: map[string]string{
"benchmark": benchmark,
"bot": record.Builder,
"master": cfg.Master,
},
Results: PopulateResults(results),
Links: map[string]string{
"Test stdio": fmt.Sprintf("[Build Log](https://ci.chromium.org/b/%s)", record.BuildID),
},
}
outputFileName := fmt.Sprintf("%s-%s-%s-%s.json", record.BuildID, benchmark, record.Builder, cfg.Master)
skiaResultJSON, err := json.MarshalIndent(skiaResult, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal SkiaPerfResult for build %s, benchmark %s: %w", record.BuildID, benchmark, err)
}
if cfg.OutputDir != "" {
outputFilePath := filepath.Join(cfg.OutputDir, outputFileName)
fmt.Printf(" Writing to Output File Path: %s\n", outputFilePath)
if err := os.WriteFile(outputFilePath, skiaResultJSON, 0644); err != nil {
return fmt.Errorf("failed to write output file for build %s, benchmark %s: %w", record.BuildID, benchmark, err)
}
}
// Upload to GCS if client is configured
if cfg.GCSClient != nil {
destPath := filepath.Join(gcsPathPrefix, outputFileName)
wc := cfg.GCSClient.Bucket(cfg.GCSBucket).Object(destPath).NewWriter(ctx)
wc.ContentType = "application/json"
if _, err := wc.Write(skiaResultJSON); err != nil {
fmt.Printf("Error: Failed to write to GCS object %s: %v\n", destPath, err)
// Optionally continue to the next file if one upload fails
continue
}
if err := wc.Close(); err != nil {
fmt.Printf("Error: Failed to close GCS writer for %s: %v\n", destPath, err)
// Optionally continue to the next file
continue
}
fmt.Printf(" Successfully uploaded to gs://%s/%s\n", cfg.GCSBucket, destPath)
}
}
}
return nil
}
// PopulateResults creates a slice of SkiaResultItem from FuchsiaPerfResultItem slice.
func PopulateResults(perfResults []FuchsiaPerfResultItem) []SkiaResultItem {
// Group by TestName
tests := make(map[string][]FuchsiaPerfResultItem)
for _, res := range perfResults {
tests[res.TestName] = append(tests[res.TestName], res)
}
var skiaResults []SkiaResultItem
for testName, results := range tests {
newUnit, direction := MapUnitAndDirection(results[0].Unit)
unitStr := newUnit + "_" + direction
improvementDirection := "up"
if strings.Contains(unitStr, "smallerIsBetter") {
improvementDirection = "down"
}
// Calculate stats
if len(results) == 0 {
continue
}
baseStats, avgVal, stdev := CalculateStats(results)
// Base item with all stats
skiaResults = append(skiaResults, SkiaResultItem{
Key: SkiaResultKey{
Test: testName,
Unit: unitStr,
ImprovementDirection: improvementDirection,
},
Measurements: Measurements{Stat: baseStats},
})
// Average item
skiaResults = append(skiaResults, SkiaResultItem{
Key: SkiaResultKey{
Test: testName + "_avg",
Unit: unitStr,
ImprovementDirection: improvementDirection,
},
Measurements: Measurements{
Stat: []StatItem{{Value: "value", Measurement: avgVal}, {Value: "error", Measurement: stdev}},
},
})
}
return skiaResults
}
// CalculateStats calculates the statistics for a slice of FuchsiaPerfResultItem.
func CalculateStats(results []FuchsiaPerfResultItem) ([]StatItem, float64, float64) {
if len(results) == 0 {
return nil, 0.0, 0.0
}
// Get the original unit to determine if conversion is needed
originalUnit := strings.SplitN(results[0].Unit, "_", 2)[0]
convertValue := func(val float64) float64 {
switch originalUnit {
case "nanoseconds", "ns":
return val / 1e6 // to milliseconds
case "bytes/second":
return val / (1024 * 1024) // to MiB/s, but unit becomes unitless
}
return val
}
var values []float64
for _, r := range results {
values = append(values, convertValue(r.Value))
}
if len(values) == 0 {
return nil, 0.0, 0.0
}
minVal := values[0]
maxVal := values[0]
sumVal := 0.0
for _, v := range values {
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
sumVal += v
}
count := float64(len(values))
avgVal := sumVal / count
stdev := stdDev(values)
stats := []StatItem{
{Value: "value", Measurement: values[0]},
{Value: "error", Measurement: stdev},
{Value: "count", Measurement: count},
{Value: "max", Measurement: maxVal},
{Value: "min", Measurement: minVal},
{Value: "sum", Measurement: sumVal},
}
return stats, avgVal, stdev
}
// MapUnitAndDirection converts the input unit and direction to the Skia Perf format.
// It returns the new unit and the improvement direction.
func MapUnitAndDirection(input string) (string, string) {
parts := strings.SplitN(input, "_", 2)
inputUnit := parts[0]
inputDirection := ""
if len(parts) > 1 {
inputDirection = parts[1]
}
newUnit, ok := unitMap[inputUnit]
if !ok {
fmt.Printf("Warning: Unrecognized unit: %s, defaulting to unitless\n", inputUnit)
newUnit = "unitless"
}
direction := ""
if inputDirection == "biggerIsBetter" || inputDirection == "smallerIsBetter" {
direction = inputDirection
} else if inputDirection == "" {
if val, ok := defaultImprovementDirection[newUnit]; ok {
direction = val
} else {
direction = "smallerIsBetter" // Default direction
}
} else {
fmt.Printf("Warning: Invalid direction: %s, using default\n", inputDirection)
direction = "smallerIsBetter" // Default direction
}
return newUnit, direction
}