diff --git a/Plugins/BenchmarkCommandPlugin/BenchmarkCommandPlugin.swift b/Plugins/BenchmarkCommandPlugin/BenchmarkCommandPlugin.swift index 319c0586..f84514f2 100644 --- a/Plugins/BenchmarkCommandPlugin/BenchmarkCommandPlugin.swift +++ b/Plugins/BenchmarkCommandPlugin/BenchmarkCommandPlugin.swift @@ -41,8 +41,12 @@ import Glibc let quietRunning = argumentExtractor.extractFlag(named: "quiet") let noProgress = argumentExtractor.extractFlag(named: "no-progress") let checkAbsoluteThresholdsPath = argumentExtractor.extractOption(named: "check-absolute-path") + let skipLoadingBenchmarks = argumentExtractor.extractFlag(named: "skip-loading-benchmark-targets") let checkAbsoluteThresholds = checkAbsoluteThresholdsPath.count > 0 ? 1 : argumentExtractor.extractFlag(named: "check-absolute") + let runCount = argumentExtractor.extractOption(named: "run-count") + let relative = argumentExtractor.extractFlag(named: "relative") + let range = argumentExtractor.extractFlag(named: "range") let groupingToUse = argumentExtractor.extractOption(named: "grouping") let metricsToUse = argumentExtractor.extractOption(named: "metric") let timeUnits = argumentExtractor.extractOption(named: "time-units") @@ -233,6 +237,8 @@ import Glibc throw MyError.invalidArgument } + var totalRunCount = 1 + var skipLoadingBenchmarksFlagIsValid = skipLoadingBenchmarks == 0 if commandToPerform == .thresholds { guard positionalArguments.count > 0, let thresholdsOperation = ThresholdsOperation(rawValue: positionalArguments.removeFirst()) @@ -262,11 +268,30 @@ import Glibc ) throw MyError.invalidArgument } - if positionalArguments.count > 0 { + let usesExistingBaseline = positionalArguments.count > 0 + if usesExistingBaseline { shouldBuildTargets = false } - break + let requestedRunCount = runCount.first.flatMap { Int($0) } ?? 1 + /// These update the run count to 5 by default if it's set to 1. + /// Using relative/range flags doesn't mean anything if we're not running multiple times. + /// The benchmarks will need to be run multiple times in order to be able to calculate a + /// relative/range of thresholds which satisfy all benchmark runs. + if relative > 0 { + args.append("--wants-relative-thresholds") + if !usesExistingBaseline { + totalRunCount = requestedRunCount < 2 ? 5 : requestedRunCount + } + } + if range > 0 { + args.append("--wants-range-thresholds") + if !usesExistingBaseline { + totalRunCount = requestedRunCount < 2 ? 5 : requestedRunCount + } + } case .check: + skipLoadingBenchmarksFlagIsValid = true + shouldBuildTargets = skipLoadingBenchmarks == 0 let validRange = 0...1 guard validRange.contains(positionalArguments.count) else { print( @@ -281,6 +306,19 @@ import Glibc } } + if !skipLoadingBenchmarksFlagIsValid { + print("") + print( + "Flag --skip-loading-benchmark-targets is only valid for 'thresholds check' operations." + ) + print("") + print(help) + print("") + print("Please visit https://github.com/ordo-one/package-benchmark for more in-depth documentation") + print("") + throw MyError.invalidArgument + } + if commandToPerform == .baseline { guard positionalArguments.count > 0, let baselineOperation = BaselineOperation(rawValue: positionalArguments.removeFirst()) @@ -463,53 +501,108 @@ import Glibc var failedBenchmarkCount = 0 - try withCStrings(args) { cArgs in - if debug > 0 { - print("To debug, start \(benchmarkToolName) in LLDB using:") - print("lldb \(benchmarkTool.string)") - print("") - print("Then launch \(benchmarkToolName) with:") - print("run \(args.dropFirst().joined(separator: " "))") - print("") - return - } + var allFailureCount = 0 + let results: [Result] = (0.. 1 { + args += ["--run-number", "\(runIdx + 1)"] + if quietRunning == 0 { + print( + """ - var pid: pid_t = 0 - var status = posix_spawn(&pid, benchmarkTool.string, nil, nil, cArgs, environ) - - if status == 0 { - if waitpid(pid, &status, 0) != -1 { - // Ok, this sucks, but there is no way to get a C support target for plugins and - // the way the status is extracted portably is with macros - so we just need to - // reimplement the logic here in Swift according to the waitpid man page to - // get some nicer feedback on failure reason. - guard let waitStatus = ExitCode(rawValue: (status & 0xFF00) >> 8) else { - print("One or more benchmarks returned an unexpected return code \(status)") - throw MyError.benchmarkUnexpectedReturnCode + Running the command multiple times, round \(runIdx + 1) of \(totalRunCount)... + """ + ) } - switch waitStatus { - case .success: - break - case .baselineNotFound: - throw MyError.baselineNotFound - case .genericFailure: - print("One or more benchmark suites crashed during runtime.") - throw MyError.benchmarkCrashed - case .thresholdRegression: - throw MyError.benchmarkThresholdRegression - case .thresholdImprovement: - throw MyError.benchmarkThresholdImprovement - case .benchmarkJobFailed: - failedBenchmarkCount += 1 - case .noPermissions: - throw MyError.noPermissions + } + + return Result { + try withCStrings(args) { cArgs in + /// We'll decrement this in the success path + allFailureCount += 1 + + if debug > 0 { + print("To debug, start \(benchmarkToolName) in LLDB using:") + print("lldb \(benchmarkTool.string)") + print("") + print("Then launch \(benchmarkToolName) with:") + print("run \(args.dropFirst().joined(separator: " "))") + print("") + return + } + + var pid: pid_t = 0 + var status = posix_spawn(&pid, benchmarkTool.string, nil, nil, cArgs, environ) + + if status == 0 { + if waitpid(pid, &status, 0) != -1 { + // Ok, this sucks, but there is no way to get a C support target for plugins and + // the way the status is extracted portably is with macros - so we just need to + // reimplement the logic here in Swift according to the waitpid man page to + // get some nicer feedback on failure reason. + guard let waitStatus = ExitCode(rawValue: (status & 0xFF00) >> 8) else { + print("One or more benchmarks returned an unexpected return code \(status)") + throw MyError.benchmarkUnexpectedReturnCode + } + switch waitStatus { + case .success: + allFailureCount -= 1 + case .baselineNotFound: + throw MyError.baselineNotFound + case .genericFailure: + print("One or more benchmark suites crashed during runtime.") + throw MyError.benchmarkCrashed + case .thresholdRegression: + throw MyError.benchmarkThresholdRegression + case .thresholdImprovement: + throw MyError.benchmarkThresholdImprovement + case .benchmarkJobFailed: + failedBenchmarkCount += 1 + case .noPermissions: + throw MyError.noPermissions + } + } else { + print( + "waitpid() for pid \(pid) returned a non-zero exit code \(status), errno = \(errno)" + ) + exit(errno) + } + } else { + print("Failed to run BenchmarkTool, posix_spawn() returned [\(status)]") + } } - } else { - print("waitpid() for pid \(pid) returned a non-zero exit code \(status), errno = \(errno)") - exit(errno) } - } else { - print("Failed to run BenchmarkTool, posix_spawn() returned [\(status)]") + } + + switch results.count { + case ...0: + throw MyError.unknownFailure + case 1: + try results[0].get() + default: + if allFailureCount > 0 { + print( + """ + Ran BenchmarkTool \(results.count) times, but it failed \(allFailureCount) times. + Will exit with the first failure. + + """ + ) + guard + let failure = results.first(where: { result in + switch result { + case .failure: + return true + case .success: + return false + } + }) + else { + throw MyError.unknownFailure + } + try failure.get() } } @@ -529,5 +622,6 @@ import Glibc case noPermissions = 6 case invalidArgument = 101 case buildFailed = 102 + case unknownFailure = 103 } } diff --git a/Plugins/BenchmarkHelpGenerator/BenchmarkHelpGenerator.swift b/Plugins/BenchmarkHelpGenerator/BenchmarkHelpGenerator.swift index 166ea313..8b5902d8 100644 --- a/Plugins/BenchmarkHelpGenerator/BenchmarkHelpGenerator.swift +++ b/Plugins/BenchmarkHelpGenerator/BenchmarkHelpGenerator.swift @@ -153,6 +153,42 @@ struct Benchmark: AsyncParsableCommand { ) var checkAbsolute = false + @Flag( + name: .long, + help: """ + Specifies that thresholds check command should skip loading benchmark targets. + Use this flag to skip unnecessary building of benchmark targets and loading of benchmark results, to save time. + This flag is specially useful when combined with static threshold files that contain the newly supported relative or range thresholds. + With such a set up, you'll save the time needed to build the benchmark targets and the thresholds check operation + will only read the threshold tolerance values from the static files. + """ + ) + var skipLoadingBenchmarks = false + + @Option( + name: .long, + help: """ + The number of times to run each benchmark in thresholds update operation. + This is only valid when --relative or --range are also specified. + When combined with --relative or --range flags, this option will run the benchmarks multiple times to calculate + relative or range thresholds, and each time it'll widen the threshold tolerances according to the new result. + Defaults to 1. + """ + ) + var runCount: Int? + + @Flag( + name: .long, + help: "Specifies that thresholds update command should output relative thresholds to the static files." + ) + var relative = false + + @Flag( + name: .long, + help: "Specifies that thresholds update command should output min-max range thresholds to the static files." + ) + var range = false + @Option( name: .long, help: diff --git a/Plugins/BenchmarkTool/BenchmarkTool+Baselines.swift b/Plugins/BenchmarkTool/BenchmarkTool+Baselines.swift index 9e97e2b1..edd3d615 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+Baselines.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+Baselines.swift @@ -449,8 +449,7 @@ extension BenchmarkBaseline: Equatable { benchmarks, name: lhsBenchmarkIdentifier.name, target: lhsBenchmarkIdentifier.target, - metric: lhsBenchmarkResult.metric, - defaultThresholds: lhsBenchmarkResult.thresholds ?? BenchmarkThresholds.default + metric: lhsBenchmarkResult.metric ) let deviationResults = lhsBenchmarkResult.deviationsComparedWith( @@ -483,7 +482,7 @@ extension BenchmarkBaseline: Equatable { public func failsAbsoluteThresholdChecks( benchmarks: [Benchmark], p90Thresholds: [BenchmarkIdentifier: - [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]] + [BenchmarkMetric: BenchmarkThreshold]] ) -> BenchmarkResult.ThresholdDeviations { var allDeviationResults = BenchmarkResult.ThresholdDeviations() diff --git a/Plugins/BenchmarkTool/BenchmarkTool+Export+InfluxCSVFormatter.swift b/Plugins/BenchmarkTool/BenchmarkTool+Export+InfluxCSVFormatter.swift index f8b81ca5..e0b8921a 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+Export+InfluxCSVFormatter.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+Export+InfluxCSVFormatter.swift @@ -54,7 +54,8 @@ class InfluxCSVFormatter { let memory = machine.memory if header { - let dataTypeHeader = "#datatype tag,tag,tag,tag,tag,tag,tag,tag,tag,double,double,double,long,long,dateTime\n" + let dataTypeHeader = + "#datatype tag,tag,tag,tag,tag,tag,tag,tag,tag,double,double,double,long,long,dateTime\n" finalFileFormat.append(dataTypeHeader) let headers = "measurement,hostName,processoryType,processors,memory,kernelVersion,metric,unit,test,percentile,value,test_average,iterations,warmup_iterations,time\n" diff --git a/Plugins/BenchmarkTool/BenchmarkTool+Export+JMHFormatter.swift b/Plugins/BenchmarkTool/BenchmarkTool+Export+JMHFormatter.swift index 15d00576..8c5ef5d3 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+Export+JMHFormatter.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+Export+JMHFormatter.swift @@ -34,7 +34,7 @@ extension JMHPrimaryMetric { let factor = result.metric.countable == false ? 1_000 : 1 for p in percentiles { - percentileValues[String(p)] = Statistics.roundToDecimalplaces( + percentileValues[String(p)] = Statistics.roundToDecimalPlaces( Double(histogram.valueAtPercentile(p)) / Double(factor), 3 ) @@ -42,15 +42,15 @@ extension JMHPrimaryMetric { for value in histogram.recordedValues() { for _ in 0.. OutputPath { var outputPath: FilePath if let path = (thresholdsOperation == nil) ? path : thresholdsPath { if path == "stdout" { - print(exportData) - return + return .stdout } let subPath = FilePath(path).removingRoot() @@ -57,6 +56,24 @@ extension BenchmarkTool { outputPath.append(csvFile.components) + return .file(outputPath) + } + + func write( + exportData: String, + hostIdentifier: String? = nil, + fileName: String = "results.txt" + ) throws { + // Set up desired output path and create any intermediate directories for structure as required: + let outputPath: FilePath + switch self.outputPath(hostIdentifier: hostIdentifier, fileName: fileName) { + case .stdout: + print(exportData) + return + case .file(let path): + outputPath = path + } + print("Writing to \(outputPath)") printFailedBenchmarks() @@ -254,15 +271,124 @@ extension BenchmarkTool { } } case .metricP90AbsoluteThresholds: + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try baseline.results.forEach { key, results in - let jsonEncoder = JSONEncoder() - jsonEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let fileName = cleanupStringForShellSafety("\(key.target).\(key.name).p90.json") + + var outputResults: [BenchmarkMetric: BenchmarkThreshold] = [:] + + let wantsRelative = self.wantsRelativeThresholds + let wantsRange = self.wantsRangeThresholds + let wantsRelativeOrRange = wantsRelative || wantsRange + + /// If it's the first run or if relative/range are not specified, then + /// override the thresholds file with the new results we have. + /// If runNumber is zero that'd mean this is not part of a multi-run benchmark, + /// so we'll still try to update thresholds instead of overriding them. + if runNumber == 1 || !wantsRelativeOrRange { + for values in results { + outputResults[values.metric] = .absolute( + Int(values.statistics.histogram.valueAtPercentile(90.0)) + ) + } - var outputResults: [String: BenchmarkThresholds.AbsoluteThreshold] = [:] - results.forEach { values in - outputResults[values.metric.rawDescription] = Int( - values.statistics.histogram.valueAtPercentile(90.0) - ) + } else { + /// If it's not the first run and any of relative/range are specified, then + /// merge the new results with the existing thresholds. + + var currentThresholds: [BenchmarkMetric: BenchmarkThreshold]? + + switch self.outputPath(fileName: fileName) { + case .stdout: + currentThresholds = nil + case .file(let path): + currentThresholds = Self.makeBenchmarkThresholds( + path: path, + benchmarkIdentifier: key + ) + } + + outputResults = currentThresholds ?? [:] + + for values in results { + let metric = values.metric + let newValue = values.statistics.histogram.valueAtPercentile(90.0) + + var relativeResult: BenchmarkThreshold.RelativeOrRange.Relative? + var rangeResult: BenchmarkThreshold.RelativeOrRange.Range? + if wantsRelativeOrRange { + let newValue = Double(Int(truncatingIfNeeded: newValue)) + /// Prefer Double to keep precision + var min = Double(newValue) + var max = Double(newValue) + + /// Load current min/max values from static thresholds file + switch currentThresholds?[metric] { + case .absolute(let value): + min = Double(value) + max = Double(value) + case .relativeOrRange(let relativeOrRange): + /// If for "wantsRelative", we prefer to use the min/max + if let range = relativeOrRange.range { + min = Double(range.min) + max = Double(range.max) + } else if let relative = relativeOrRange.relative { + let base = Double(relative.base) + let diff = (base / 100) * relative.tolerancePercentage + min = base - diff + max = base + diff + } + case .none: break + } + + /// Update the min/max values + min = Swift.min(min, Double(newValue)) + max = Swift.max(max, Double(newValue)) + + /// If min == max, it won't make a difference than using .absolute + if min != max { + if wantsRange { + rangeResult = .init(min: Int(min), max: Int(max)) + } + + if wantsRelative { + /// Calculate base and tolerancePercentage + let base = (min + max) / 2 + let diff = max - base + let diffPercentage = (base == 0) ? 0 : (diff / base * 100) + let tolerancePercentage = Statistics.roundToDecimalPlaces(diffPercentage, 2, .up) + + relativeResult = .init( + base: Int(base), + tolerancePercentage: tolerancePercentage + ) + } + } + } + + if relativeResult == nil && rangeResult == nil { + outputResults[metric] = .absolute(Int(truncatingIfNeeded: newValue)) + } else { + /// If we have a relative/range threshold but it's not specified in the command for + /// this run to update it, we still would like to keep the non-updated existing threshold. + switch currentThresholds?[metric] { + case .relativeOrRange(let currentRelativeOrRange): + relativeResult = relativeResult ?? currentRelativeOrRange.relative + rangeResult = rangeResult ?? currentRelativeOrRange.range + case .absolute, .none: + break + } + + outputResults[metric] = .relativeOrRange( + BenchmarkThreshold.RelativeOrRange( + relative: relativeResult, + range: rangeResult + ) + ) + } + } } let jsonResultData = try jsonEncoder.encode(outputResults) @@ -270,7 +396,7 @@ extension BenchmarkTool { if let stringOutput = String(data: jsonResultData, encoding: .utf8) { try write( exportData: stringOutput, - fileName: cleanupStringForShellSafety("\(key.target).\(key.name).p90.json") + fileName: fileName ) } else { print("Failed to encode json for \(outputResults)") diff --git a/Plugins/BenchmarkTool/BenchmarkTool+Operations.swift b/Plugins/BenchmarkTool/BenchmarkTool+Operations.swift index 28275c8d..b3454b83 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+Operations.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+Operations.swift @@ -29,7 +29,7 @@ extension BenchmarkTool { let benchmarkReply = try read() switch benchmarkReply { - case let .list(benchmark): + case .list(let benchmark): benchmark.executablePath = benchmarkPath benchmark.target = FilePath(benchmarkPath).lastComponent!.description if metrics.isEmpty == false { @@ -38,7 +38,7 @@ extension BenchmarkTool { benchmarks.append(benchmark) case .end: break outerloop - case let .error(description): + case .error(let description): failBenchmark(description) break outerloop default: @@ -55,12 +55,12 @@ extension BenchmarkTool { let benchmarkReply = try read() switch benchmarkReply { - case let .result(benchmark: benchmark, results: results): + case .result(benchmark: let benchmark, results: let results): let filteredResults = results.filter { benchmark.configuration.metrics.contains($0.metric) } benchmarkResults[BenchmarkIdentifier(target: target, name: benchmark.name)] = filteredResults case .end: break outerloop - case let .error(description): + case .error(let description): failBenchmark(description, exitCode: .benchmarkJobFailed, "\(target)/\(benchmark.name)") benchmarkResults[BenchmarkIdentifier(target: target, name: benchmark.name)] = [] @@ -79,12 +79,17 @@ extension BenchmarkTool { return cleanedString } - struct NameAndTarget: Hashable { + struct NameAndTarget: Hashable, Comparable { let name: String let target: String + + static func < (lhs: NameAndTarget, rhs: NameAndTarget) -> Bool { + (lhs.target, lhs.name) < (rhs.target, rhs.name) + } } mutating func postProcessBenchmarkResults() throws { + // Turn on buffering again for output setvbuf(stdout, nil, _IOFBF, Int(BUFSIZ)) @@ -100,7 +105,7 @@ extension BenchmarkTool { case .read: print("Reading thresholds from \"\(thresholdsPath)\"") - var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]] = [:] + var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThreshold]] = [:] try benchmarks.forEach { benchmark in if try shouldIncludeBenchmark(benchmark.baseName) { if let thresholds = BenchmarkTool.makeBenchmarkThresholds( @@ -146,7 +151,7 @@ extension BenchmarkTool { } } - var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]] = [:] + var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThreshold]] = [:] if noProgress == false { print("") @@ -299,7 +304,7 @@ extension BenchmarkTool { } } - var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]] = + var p90Thresholds: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThreshold]] = [:] if let benchmarkPath = checkAbsolutePath { // load statically defined thresholds for .p90 diff --git a/Plugins/BenchmarkTool/BenchmarkTool+PrettyPrinting.swift b/Plugins/BenchmarkTool/BenchmarkTool+PrettyPrinting.swift index a47ead05..29dc39ee 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+PrettyPrinting.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+PrettyPrinting.swift @@ -536,99 +536,111 @@ extension BenchmarkTool { guard quiet == false else { return } let metrics = deviationResults.map(\.metric).unique() - // Get a unique set of all name/target pairs that have threshold deviations, sorted lexically: - let namesAndTargets = deviationResults.map { NameAndTarget(name: $0.name, target: $0.target) } - .unique().sorted { ($0.target, $0.name) < ($1.target, $1.name) } - namesAndTargets.forEach { nameAndTarget in + var groupedDeviations = [NameAndTarget: [BenchmarkResult.ThresholdDeviation]]() + groupedDeviations.reserveCapacity(deviationResults.count) + for result in deviationResults { + let nameAndTarget = NameAndTarget(name: result.name, target: result.target) + groupedDeviations[nameAndTarget, default: []].append(result) + } + let sortedGroupedDeviations = groupedDeviations.sorted(by: { $0.key < $1.key }) + for (nameAndTarget, deviationResults) in sortedGroupedDeviations { printMarkdown("```") "\(deviationTitle) for \(nameAndTarget.target):\(nameAndTarget.name)".printAsHeader(addWhiteSpace: false) printMarkdown("```") metrics.forEach { metric in + let filteredDeviations = deviationResults.filter { $0.metric == metric } - let relativeResults = deviationResults.filter { - $0.name == nameAndTarget.name && $0.target == nameAndTarget.target && $0.metric == metric - && $0.relative == true - } - let absoluteResults = deviationResults.filter { - $0.name == nameAndTarget.name && $0.target == nameAndTarget.target && $0.metric == metric - && $0.relative == false - } let width = 40 let percentileWidth = 15 - // The baseValue is the new baseline that we're using as the comparison base, so... - if absoluteResults.isEmpty == false { - let absoluteTable = TextTable { - [ - Column( - title: - "\(metric.description) (\(metric.countable ? $0.units.description : $0.units.timeDescription), Δ)", - value: $0.percentile, - width: width, - align: .left - ), - Column( - title: "\(baselineName)", - value: $0.comparisonValue, - width: percentileWidth, - align: .right - ), - Column( - title: "\(comparingBaselineName)", - value: $0.baseValue, - width: percentileWidth, - align: .right - ), - Column(title: "Difference Δ", value: $0.difference, width: percentileWidth, align: .right), - Column( - title: "Threshold Δ", - value: $0.differenceThreshold, - width: percentileWidth, - align: .right - ), - ] - } + let table = TextTable { + var columns: [Column] = [] + columns.reserveCapacity(4) - absoluteTable.print(absoluteResults, style: format.tableStyle) - } + let sign = + switch $0.deviation { + case .absolute: "Δ" + case .relative: "%" + case .range: "↔" + } + let diffSign = + switch $0.deviation { + case .absolute, .range: "Δ" + case .relative: "%" + } + let unitDescription = metric.countable ? $0.units.description : $0.units.timeDescription + columns.append( + Column( + title: "\(metric.description) (\(unitDescription), \(sign))", + value: $0.percentile, + width: width, + align: .left + ) + ) - if relativeResults.isEmpty == false { - let relativeTable = TextTable { - [ - Column( - title: - "\(metric.description) (\(metric.countable ? $0.units.description : $0.units.timeDescription), %)", - value: $0.percentile, - width: width, - align: .left - ), - Column( - title: "\(baselineName)", - value: $0.comparisonValue, - width: percentileWidth, - align: .right - ), - Column( - title: "\(comparingBaselineName)", - value: $0.baseValue, - width: percentileWidth, - align: .right - ), - Column(title: "Difference %", value: $0.difference, width: percentileWidth, align: .right), + let baseValue = $0.baseValue + + // If absolute or relative, we can calculate their columns together + let comparisonValue: String + let difference: String + let tolerance: String? + switch $0.deviation { + case .absolute(let compareTo, let diff, let tol): + comparisonValue = "\(compareTo)" + difference = diff.description + tolerance = tol.description + case .relative(let compareTo, let diff, let tol): + comparisonValue = "\(compareTo)" + difference = Statistics.roundToDecimalPlaces(diff, 2).description + tolerance = Statistics.roundToDecimalPlaces(tol, 2).description + case .range(let min, let max): + comparisonValue = "\(min) ... \(max)" + let diff = baseValue >= max ? baseValue - max : baseValue - min + difference = "\(diff)" + tolerance = nil + } + + columns.append(contentsOf: [ + Column( + title: baselineName, + value: comparisonValue, + width: percentileWidth, + align: .right + ), + Column( + title: comparingBaselineName, + value: baseValue, + width: percentileWidth, + align: .right + ), + Column( + title: "Difference \(diffSign)", + value: difference, + width: percentileWidth, + align: .right + ), + ]) + + if let tolerance = tolerance { + columns.append( Column( - title: "Threshold %", - value: $0.differenceThreshold, + title: "Tolerance \(diffSign)", + value: tolerance, width: percentileWidth, align: .right - ), - ] + ) + ) } - relativeTable.print(relativeResults, style: format.tableStyle) + return columns } + + table.print(filteredDeviations.filter(\.deviation.isRange), style: format.tableStyle) + table.print(filteredDeviations.filter(\.deviation.isAbsolute), style: format.tableStyle) + table.print(filteredDeviations.filter(\.deviation.isRelative), style: format.tableStyle) } } } diff --git a/Plugins/BenchmarkTool/BenchmarkTool+ReadP90AbsoluteThresholds.swift b/Plugins/BenchmarkTool/BenchmarkTool+ReadP90AbsoluteThresholds.swift index 5fad206b..0c304d73 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+ReadP90AbsoluteThresholds.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+ReadP90AbsoluteThresholds.swift @@ -33,7 +33,7 @@ extension BenchmarkTool { static func makeBenchmarkThresholds( path: String, benchmarkIdentifier: BenchmarkIdentifier - ) -> [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]? { + ) -> [BenchmarkMetric: BenchmarkThreshold]? { var path = FilePath(path) if path.isAbsolute { path.append("\(benchmarkIdentifier.target).\(benchmarkIdentifier.name).p90.json") @@ -44,8 +44,23 @@ extension BenchmarkTool { path = cwdPath } - var p90Thresholds: [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold] = [:] - var p90ThresholdsRaw: [String: BenchmarkThresholds.AbsoluteThreshold]? + return makeBenchmarkThresholds(path: path, benchmarkIdentifier: benchmarkIdentifier) + } + + /// `makeBenchmarkThresholds` is a convenience function for reading p90 static thresholds that previously have been exported with `metricP90AbsoluteThresholds` + /// + /// - Parameters: + /// - path: The path where the `Thresholds` directory should be located, containing static thresholds files using the naming pattern: + /// `moduleName.benchmarkName.p90.json` + /// - moduleName: The name of the benchmark module, can be extracted in the benchmark using: + /// `String("\(#fileID)".prefix(while: { $0 != "/" }))` + /// - benchmarkName: The name of the benchmark + /// - Returns: A dictionary with static benchmark thresholds per metric or nil if the file could not be found or read + static func makeBenchmarkThresholds( + path: FilePath, + benchmarkIdentifier: BenchmarkIdentifier + ) -> [BenchmarkMetric: BenchmarkThreshold]? { + var p90Thresholds: [BenchmarkMetric: BenchmarkThreshold] = [:] do { let fileDescriptor = try FileDescriptor.open(path, .readOnly, options: [], permissions: .ownerRead) @@ -66,19 +81,11 @@ extension BenchmarkTool { readBytes.append(contentsOf: nextBytes) } - p90ThresholdsRaw = try JSONDecoder() + p90Thresholds = try JSONDecoder() .decode( - [String: BenchmarkThresholds.AbsoluteThreshold].self, + [BenchmarkMetric: BenchmarkThreshold].self, from: Data(readBytes) ) - - if let p90ThresholdsRaw { - p90ThresholdsRaw.forEach { metric, threshold in - if let metric = BenchmarkMetric(argument: metric) { - p90Thresholds[metric] = threshold - } - } - } } catch { print( "Failed to read file at \(path) [\(String(reflecting: error))] \(Errno(rawValue: errno).description)" diff --git a/Plugins/BenchmarkTool/BenchmarkTool+Thresholds.swift b/Plugins/BenchmarkTool/BenchmarkTool+Thresholds.swift index 3173b918..2d2d0648 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool+Thresholds.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool+Thresholds.swift @@ -16,16 +16,17 @@ private let percentileWidth = 20 private let maxDescriptionWidth = 40 private struct ThresholdsTableEntry { + enum Values { + case absolute(p90: Int, absoluteTolerance: Int, relativeTolerance: Double) + case relativeOrRange(BenchmarkThreshold.RelativeOrRange) + } + var description: String - var p90: Int - var absolute: Int - var relative: Double + var value: Values } extension BenchmarkTool { - func printThresholds( - _ staticThresholdsPerBenchmark: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThresholds.AbsoluteThreshold]] - ) { + func printThresholds(_ staticThresholdsPerBenchmark: [BenchmarkIdentifier: [BenchmarkMetric: BenchmarkThreshold]]) { guard !staticThresholdsPerBenchmark.isEmpty else { print("No thresholds defined.") @@ -35,13 +36,42 @@ extension BenchmarkTool { print("") var tableEntries: [ThresholdsTableEntry] = [] - let table = TextTable { - [ - Column(title: "Metric", value: "\($0.description)", width: maxDescriptionWidth, align: .left), - Column(title: "Threshold .p90", value: $0.p90, width: percentileWidth, align: .right), - Column(title: "Allowed %", value: $0.relative, width: percentileWidth, align: .right), - Column(title: "Allowed Δ", value: $0.absolute, width: percentileWidth, align: .right), - ] + let table = TextTable { entry in + var columns: [Column] = [] + columns.reserveCapacity(4) + + columns.append( + Column(title: "Metric", value: entry.description, width: maxDescriptionWidth, align: .left), + ) + + switch entry.value { + case .absolute(let p90, let absoluteTolerance, let relativeTolerance): + columns.append(contentsOf: [ + Column(title: "Threshold .p90", value: p90, width: percentileWidth, align: .right), + Column(title: "Allowed %", value: relativeTolerance, width: percentileWidth, align: .right), + Column(title: "Allowed Δ", value: absoluteTolerance, width: percentileWidth, align: .right), + ]) + case .relativeOrRange(let relativeOrRange): + if let relative = relativeOrRange.relative { + let tolerancePercentage = Statistics.roundToDecimalPlaces(relative.tolerancePercentage, 2) + columns.append(contentsOf: [ + Column( + title: "Allowed %", + value: "\(relative.base) ± \(tolerancePercentage)%", + width: percentileWidth, + align: .right + ) + ]) + } + if let range = relativeOrRange.range { + columns.append(contentsOf: [ + Column(title: "Allowed min", value: "\(range.min)", width: percentileWidth, align: .right), + Column(title: "Allowed max", value: "\(range.max)", width: percentileWidth, align: .right), + ]) + } + } + + return columns } staticThresholdsPerBenchmark.forEach { benchmarkIdentifier, staticThresholds in @@ -50,25 +80,34 @@ extension BenchmarkTool { let thresholdDeviations = benchmarks.first(where: { benchmarkIdentifier - == .init( - target: $0.target, - name: $0.name - ) + == .init(target: $0.target, name: $0.name) })? .configuration.thresholds ?? .init() staticThresholds.forEach { threshold in - let absoluteThreshold = thresholdDeviations[threshold.key]?.absolute[.p90] ?? 0 - let relativeThreshold = thresholdDeviations[threshold.key]?.relative[.p90] ?? 0 + switch threshold.value { + case .absolute(let value): + let absoluteThreshold = thresholdDeviations[threshold.key]?.absolute[.p90] ?? 0 + let relativeThreshold = thresholdDeviations[threshold.key]?.relative[.p90] ?? 0 - tableEntries.append( - .init( - description: threshold.key.description, - p90: threshold.value, - absolute: absoluteThreshold, - relative: relativeThreshold + tableEntries.append( + .init( + description: threshold.key.description, + value: .absolute( + p90: value, + absoluteTolerance: absoluteThreshold, + relativeTolerance: relativeThreshold + ) + ) + ) + case .relativeOrRange(let relativeOrRange): + tableEntries.append( + .init( + description: threshold.key.description, + value: .relativeOrRange(relativeOrRange) + ) ) - ) + } } table.print(tableEntries, style: format.tableStyle) tableEntries = [] diff --git a/Plugins/BenchmarkTool/BenchmarkTool.swift b/Plugins/BenchmarkTool/BenchmarkTool.swift index a2ecf655..b35ee1f9 100644 --- a/Plugins/BenchmarkTool/BenchmarkTool.swift +++ b/Plugins/BenchmarkTool/BenchmarkTool.swift @@ -71,6 +71,15 @@ struct BenchmarkTool: AsyncParsableCommand { @Option(name: .long, help: "The operation to perform for thresholds") var thresholdsOperation: ThresholdsOperation? + @Option(name: .long, help: "The run number of the benchmark tool. Defaults to 1.") + var runNumber: Int? + + @Flag(name: .long, help: "True if we should add relative thresholds to the static files in thresholds update.") + var wantsRelativeThresholds = false + + @Flag(name: .long, help: "True if we should add min-max range thresholds to the static files in thresholds update.") + var wantsRangeThresholds = false + @Flag(name: .long, help: "True if we should suppress output") var quiet: Bool = false @@ -232,6 +241,9 @@ struct BenchmarkTool: AsyncParsableCommand { return } + // Make sure this never goes below 1 + self.runNumber = runNumber.map { max($0, 1) } + // Skip reading baselines for baseline operations not needing them if let operation = baselineOperation, [.delete, .list, .update].contains(operation) == false { try readBaselines() diff --git a/Sources/Benchmark/BenchmarkMetric.swift b/Sources/Benchmark/BenchmarkMetric.swift index b5d06096..b1032cc3 100644 --- a/Sources/Benchmark/BenchmarkMetric.swift +++ b/Sources/Benchmark/BenchmarkMetric.swift @@ -132,7 +132,7 @@ public extension BenchmarkMetric { return true case .objectAllocCount, .retainCount, .releaseCount, .retainReleaseDelta: return true - case let .custom(_, _, useScaleFactor): + case .custom(_, _, let useScaleFactor): return useScaleFactor default: return false @@ -144,7 +144,7 @@ public extension BenchmarkMetric { switch self { case .throughput: return .prefersLarger - case let .custom(_, polarity, _): + case .custom(_, let polarity, _): return polarity default: return .prefersSmaller @@ -213,7 +213,7 @@ public extension BenchmarkMetric { return "Δ" case .deltaPercentage: return "Δ %" - case let .custom(name, _, _): + case .custom(let name, _, _): return name } } @@ -417,7 +417,7 @@ public extension BenchmarkMetric { return "Δ" case .deltaPercentage: return "Δ %" - case let .custom(name, _, _): + case .custom(let name, _, _): return name } } @@ -491,4 +491,16 @@ public extension BenchmarkMetric { } } +/// `CodingKeyRepresentable` conformance enables Codable to encode/decode a dictionary with keys of +/// type `BenchmarkMetric`, as if the key type was `String` and not `BenchmarkMetric`. +extension BenchmarkMetric: CodingKeyRepresentable { + public var codingKey: any CodingKey { + self.rawDescription.codingKey + } + + public init?(codingKey: T) where T: CodingKey { + self.init(argument: codingKey.stringValue) + } +} + // swiftlint:enable cyclomatic_complexity function_body_length diff --git a/Sources/Benchmark/BenchmarkResult.swift b/Sources/Benchmark/BenchmarkResult.swift index 80350f2c..394dfe51 100644 --- a/Sources/Benchmark/BenchmarkResult.swift +++ b/Sources/Benchmark/BenchmarkResult.swift @@ -459,15 +459,45 @@ public struct BenchmarkResult: Codable, Comparable, Equatable { } public struct ThresholdDeviation { + public enum Deviation { + case absolute(comparedTo: Int, difference: Int, tolerance: Int) + case relative(comparedTo: Int, differencePercentage: Double, tolerancePercentage: Double) + case range(min: Int, max: Int) + + public var isRelative: Bool { + switch self { + case .relative: + return true + case .absolute, .range: + return false + } + } + + public var isRange: Bool { + switch self { + case .range: + return true + case .absolute, .relative: + return false + } + } + + public var isAbsolute: Bool { + switch self { + case .absolute: + return true + case .relative, .range: + return false + } + } + } + public let name: String public let target: String public let metric: BenchmarkMetric public let percentile: BenchmarkResult.Percentile public let baseValue: Int - public let comparisonValue: Int - public let difference: Int - public let differenceThreshold: Int - public let relative: Bool + public let deviation: Deviation public let units: Statistics.Units } @@ -490,56 +520,128 @@ public struct BenchmarkResult: Codable, Comparable, Equatable { func appendDeviationResultsFor( _ metric: BenchmarkMetric, _ lhs: Int, - _ rhs: Int, + /// Either absolute values or thresholds from static threshold files. + _ rhs: BenchmarkThreshold, _ percentile: Self.Percentile, - _ thresholds: BenchmarkThresholds, + /// Thresholds from 'configuration.thresholds' in the benchmark code. + _ thresholdsFromCode: BenchmarkThresholds, _ thresholdResults: inout ThresholdDeviations, _ name: String = "unknown name", _ target: String = "unknown target" ) { - let reverseComparison = metric.polarity == .prefersLarger - let absoluteDifference = (reverseComparison ? -1 : 1) * (lhs - rhs) - let relativeDifference = - (reverseComparison ? 1 : -1) * (rhs != 0 ? (100 - (100.0 * Double(lhs) / Double(rhs))) : 0.0) - - if let threshold = thresholds.relative[percentile], !(-threshold...threshold).contains(relativeDifference) { - let deviation = ThresholdDeviation( - name: name, - target: target, - metric: metric, - percentile: percentile, - baseValue: normalize(lhs), - comparisonValue: normalize(rhs), - difference: Int(Statistics.roundToDecimalplaces(relativeDifference, 1)), - differenceThreshold: Int(threshold), - relative: true, - units: Statistics.Units(timeUnits) - ) - if relativeDifference > threshold { - thresholdResults.regressions.append(deviation) - } else if relativeDifference < -threshold { - thresholdResults.improvements.append(deviation) + + let needsReverseComparison = metric.polarity == .prefersLarger + /// By default, exceeding a threshold is a regression. + /// If `needsReverseComparison` then exceeding a threshold is an improvement. + func keyPathForAppending( + exceedsThreshold: Bool + ) -> WritableKeyPath { + switch exceedsThreshold { + case true: + return needsReverseComparison ? \.improvements : \.regressions + case false: + return needsReverseComparison ? \.regressions : \.improvements } } - if let threshold = thresholds.absolute[percentile], !(-threshold...threshold).contains(absoluteDifference) { - let deviation = ThresholdDeviation( - name: name, - target: target, - metric: metric, - percentile: percentile, - baseValue: normalize(lhs), - comparisonValue: normalize(rhs), - difference: normalize(absoluteDifference), - differenceThreshold: normalize(threshold), - relative: false, - units: Statistics.Units(timeUnits) - ) + switch rhs { + case .absolute(let rhs): + let absoluteDifference = lhs - rhs + let relativeDifference = (rhs != 0 ? (Double(absoluteDifference) / Double(rhs)) : 0.0) + + if let threshold = thresholdsFromCode.relative[percentile], + !(-threshold...threshold).contains(relativeDifference) + { + let deviation = ThresholdDeviation( + name: name, + target: target, + metric: metric, + percentile: percentile, + baseValue: normalize(lhs), + deviation: .relative( + comparedTo: normalize(rhs), + differencePercentage: Statistics.roundToDecimalPlaces(relativeDifference, 2), + tolerancePercentage: Statistics.roundToDecimalPlaces(threshold, 2) + ), + units: Statistics.Units(timeUnits) + ) + let keyPath = keyPathForAppending(exceedsThreshold: relativeDifference > threshold) + thresholdResults[keyPath: keyPath].append(deviation) + } + + if let threshold = thresholdsFromCode.absolute[percentile], + !(-threshold...threshold).contains(absoluteDifference) + { + let deviation = ThresholdDeviation( + name: name, + target: target, + metric: metric, + percentile: percentile, + baseValue: normalize(lhs), + deviation: .absolute( + comparedTo: normalize(rhs), + difference: normalize(absoluteDifference), + tolerance: normalize(threshold) + ), + units: Statistics.Units(timeUnits) + ) + let keyPath = keyPathForAppending(exceedsThreshold: absoluteDifference > threshold) + thresholdResults[keyPath: keyPath].append(deviation) + } + + case .relativeOrRange(let rhs): + if thresholdsFromCode.definitelyContainsUserSpecifiedThresholds(at: percentile) { + print( + """ + Warning: Static threshold files contain relative or range thresholds for metric '\(metric)' at + percentile '\(percentile)', but 'configuration.thresholds' also contains threshold tolerance for this metric. + Will ignore 'configuration.thresholds'. + To silence this warning, remove 'configuration.thresholds' from your benchmark code of this benchmark. + + """ + ) + } + + if let relative = rhs.relative { + let relativeComparison = relative.contains(lhs) + if !relativeComparison.contains { + let relativeDifference = relativeComparison.deviation + let deviation = ThresholdDeviation( + name: name, + target: target, + metric: metric, + percentile: percentile, + baseValue: normalize(lhs), + deviation: .relative( + comparedTo: normalize(relative.base), + differencePercentage: Statistics.roundToDecimalPlaces(relativeDifference, 2), + tolerancePercentage: Statistics.roundToDecimalPlaces( + relative.tolerancePercentage, + 2 + ) + ), + units: Statistics.Units(timeUnits) + ) + let keyPath = keyPathForAppending(exceedsThreshold: relativeDifference.sign == .plus) + thresholdResults[keyPath: keyPath].append(deviation) + } + } - if absoluteDifference > threshold { - thresholdResults.regressions.append(deviation) - } else if absoluteDifference < -threshold { - thresholdResults.improvements.append(deviation) + if let range = rhs.range, !range.contains(lhs) { + let deviation = ThresholdDeviation( + name: name, + target: target, + metric: metric, + percentile: percentile, + baseValue: normalize(lhs), + deviation: .range( + min: normalize(range.min), + max: normalize(range.max) + ), + units: Statistics.Units(timeUnits) + ) + let keyPath = keyPathForAppending(exceedsThreshold: lhs > range.max) + thresholdResults[keyPath: keyPath].append(deviation) } } } @@ -564,7 +666,7 @@ public struct BenchmarkResult: Codable, Comparable, Equatable { appendDeviationResultsFor( lhs.metric, lhsPercentiles[percentile], - rhsPercentiles[percentile], + .absolute(rhsPercentiles[percentile]), Self.Percentile(rawValue: percentile)!, thresholds, &thresholdResults, @@ -579,7 +681,7 @@ public struct BenchmarkResult: Codable, Comparable, Equatable { // Absolute checks for --check-absolute, just check p90 public func deviationsAgainstAbsoluteThresholds( thresholds: BenchmarkThresholds, - p90Threshold: BenchmarkThresholds.AbsoluteThreshold, + p90Threshold: BenchmarkThreshold, name: String = "test", target: String = "test" ) -> ThresholdDeviations { diff --git a/Sources/Benchmark/BenchmarkRunner.swift b/Sources/Benchmark/BenchmarkRunner.swift index 8ed30363..4c109382 100644 --- a/Sources/Benchmark/BenchmarkRunner.swift +++ b/Sources/Benchmark/BenchmarkRunner.swift @@ -147,7 +147,7 @@ public struct BenchmarkRunner: AsyncParsableCommand, BenchmarkRunnerReadWrite { } try channel.write(.end) - case let .run(benchmarkToRun): + case .run(let benchmarkToRun): benchmark = Benchmark.benchmarks.first { $0.name == benchmarkToRun.name } if let benchmark { diff --git a/Sources/Benchmark/BenchmarkThresholds.swift b/Sources/Benchmark/BenchmarkThresholds.swift index 699df0ba..a00b049b 100644 --- a/Sources/Benchmark/BenchmarkThresholds.swift +++ b/Sources/Benchmark/BenchmarkThresholds.swift @@ -32,3 +32,229 @@ public struct BenchmarkThresholds: Codable { public let relative: RelativeThresholds public let absolute: AbsoluteThresholds } + +extension BenchmarkThresholds { + public func definitelyContainsUserSpecifiedThresholds(at percentile: BenchmarkResult.Percentile) -> Bool { + let defaultCodeThresholds = BenchmarkThresholds.default + let relative = self.relative[percentile] + let absolute = self.absolute[percentile] + var relativeNonDefaultThresholdsExist: Bool { + (relative ?? 0) != 0 + && relative != defaultCodeThresholds.relative[percentile] + } + var absoluteNonDefaultThresholdsExist: Bool { + (absolute ?? 0) != 0 + && absolute != defaultCodeThresholds.absolute[percentile] + } + return relativeNonDefaultThresholdsExist || absoluteNonDefaultThresholdsExist + } +} + +public enum BenchmarkThreshold: Codable { + /// A relative or range threshold. And that is a logical "or", meaning any or both. + public struct RelativeOrRange: Codable { + public struct Relative { + public let base: Int + public let tolerancePercentage: Double + + public init(base: Int, tolerancePercentage: Double) { + self.base = base + self.tolerancePercentage = tolerancePercentage + + if !self.checkValid() { + print( + """ + Warning: Got invalid relative threshold values. base: \(self.base), tolerancePercentage: \(self.tolerancePercentage). + These must satisfy the following conditions: + - base must be non-negative + - tolerancePercentage must be finite + - tolerancePercentage must be non-negative + - tolerancePercentage must be less than or equal to 100 + """ + ) + } + } + + /// Returns whether or not the value satisfies this relative range, as well as the + /// percentage of the deviation of the value. + public func contains(_ value: Int) -> (contains: Bool, deviation: Double) { + let diff = Double(value - base) + let allowedDiff = (Double(base) * tolerancePercentage / 100) + let allowedDiffWithPrecision = Statistics.roundToDecimalPlaces(allowedDiff, 6, .up) + let absDiffWithPrecision = Statistics.roundToDecimalPlaces(abs(diff), 6, .up) + let contains = absDiffWithPrecision <= allowedDiffWithPrecision + let deviation = (base == 0) ? 0 : diff / Double(base) * 100 + return (contains, deviation) + } + + func checkValid() -> Bool { + self.base >= 0 + && self.tolerancePercentage.isFinite + && self.tolerancePercentage >= 0 + && self.tolerancePercentage <= 100 + } + } + + public struct Range { + public let min: Int + public let max: Int + + public init(min: Int, max: Int) { + self.min = min + self.max = max + + if !self.checkValid() { + print( + """ + Warning: Got invalid range threshold values. min: \(self.min), max: \(self.max). + These must satisfy the following conditions: + - min must be less than or equal to max + """ + ) + } + } + + public func contains(_ value: Int) -> Bool { + return value >= min && value <= max + } + + func checkValid() -> Bool { + self.min <= self.max + } + } + + public let relative: Relative? + public let range: Range? + + public init(relative: Relative?, range: Range?) { + self.relative = relative + self.range = range + + if !self.checkValid() { + print( + """ + Warning: Got invalid RelativeOrRange threshold values. relative: \(String(describing: self.relative)), range: \(String(describing: self.range)). + At least one of the values must be non-nil. + """ + ) + } + } + + func checkValid() -> Bool { + self.containsAnyValue + } + + var containsAnyValue: Bool { + self.relative != nil || self.range != nil + } + + enum CodingKeys: String, CodingKey { + case base + case tolerancePercentage + case min + case max + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let base = try container.decodeIfPresent(Int.self, forKey: .base) + let tolerancePercentage = try container.decodeIfPresent(Double.self, forKey: .tolerancePercentage) + let min = try container.decodeIfPresent(Int.self, forKey: .min) + let max = try container.decodeIfPresent(Int.self, forKey: .max) + + var relative: Relative? + var range: Range? + + if let base, let tolerancePercentage { + relative = Relative(base: base, tolerancePercentage: tolerancePercentage) + + guard relative?.checkValid() != false else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: """ + RelativeOrRange thresholds object contains an invalid relative values. + base: \(base), tolerancePercentage: \(tolerancePercentage). + These must satisfy the following conditions: + - base must be non-negative + - tolerancePercentage must be finite + - tolerancePercentage must be non-negative + - tolerancePercentage must be less than or equal to 100 + """ + ) + ) + } + } + if let min, let max { + range = Range(min: min, max: max) + + guard range?.checkValid() != false else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: """ + RelativeOrRange thresholds object contains invalid min-max values. + 'min' (\(min)) and max ('\(max)') don't satisfy the requirements of min <= max. + """ + ) + ) + } + } + + self.relative = relative + self.range = range + + if !self.containsAnyValue { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: """ + RelativeOrRange thresholds object does not contain either a valid relative or range. + For relative thresholds, both 'base' (Int) and 'tolerancePercentage' (Double) must be present and valid. + For range thresholds, both 'min' (Int) and 'max' (Int) must be present and valid. + You can declare both relative and range in the same object together, or just one of them. + Example: { "min": 90, "max": 110 } + Example: { "base": 115, "tolerancePercentage": 5.5 } + Example: { "base": 115, "tolerancePercentage": 4.5, "min": 90, "max": 110 } + """ + ) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let relative { + try container.encode(relative.base, forKey: .base) + try container.encode(relative.tolerancePercentage, forKey: .tolerancePercentage) + } + if let range { + try container.encode(range.min, forKey: .min) + try container.encode(range.max, forKey: .max) + } + } + } + + case absolute(Int) + case relativeOrRange(RelativeOrRange) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self = .absolute(value) + } else { + let value = try RelativeOrRange(from: decoder) + self = .relativeOrRange(value) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .absolute(let value): + try value.encode(to: encoder) + case .relativeOrRange(let value): + try value.encode(to: encoder) + } + } +} diff --git a/Sources/Benchmark/Statistics.swift b/Sources/Benchmark/Statistics.swift index 49dec756..95639f8e 100644 --- a/Sources/Benchmark/Statistics.swift +++ b/Sources/Benchmark/Statistics.swift @@ -181,10 +181,14 @@ public final class Statistics: Codable { } // Rounds decimals for display - public static func roundToDecimalplaces(_ original: Double, _ decimals: Int = 2) -> Double { + public static func roundToDecimalPlaces( + _ original: Double, + _ decimals: Int = 2, + _ rule: FloatingPointRoundingRule = .toNearestOrEven + ) -> Double { let factor: Double = .pow(10.0, Double(decimals)) var original: Double = original * factor - original.round(.toNearestOrEven) + original.round(rule) return original / factor } } diff --git a/Tests/BenchmarkTests/BenchmarkResultTests.swift b/Tests/BenchmarkTests/BenchmarkResultTests.swift index 46170551..ee6cb29f 100644 --- a/Tests/BenchmarkTests/BenchmarkResultTests.swift +++ b/Tests/BenchmarkTests/BenchmarkResultTests.swift @@ -379,21 +379,21 @@ final class BenchmarkResultTests: XCTestCase { Benchmark.checkAbsoluteThresholds = true deviations = thirdResult.deviationsAgainstAbsoluteThresholds( thresholds: absoluteThresholdsP90, - p90Threshold: 1_497 + p90Threshold: .absolute(1_497) ) XCTAssertFalse(deviations.regressions.isEmpty) Benchmark.checkAbsoluteThresholds = true deviations = thirdResult.deviationsAgainstAbsoluteThresholds( thresholds: absoluteThresholdsP90, - p90Threshold: 1_505 + p90Threshold: .absolute(1_505) ) XCTAssertFalse(deviations.improvements.isEmpty) Benchmark.checkAbsoluteThresholds = true deviations = thirdResult.deviationsAgainstAbsoluteThresholds( thresholds: absoluteThresholdsP90, - p90Threshold: 1_501 + p90Threshold: .absolute(1_501) ) XCTAssertTrue(deviations.improvements.isEmpty && deviations.regressions.isEmpty) } diff --git a/Tests/BenchmarkTests/StatisticsTests.swift b/Tests/BenchmarkTests/StatisticsTests.swift index f330a15c..50d26c0b 100644 --- a/Tests/BenchmarkTests/StatisticsTests.swift +++ b/Tests/BenchmarkTests/StatisticsTests.swift @@ -26,7 +26,7 @@ final class StatisticsTests: XCTestCase { stats.add(measurement) } - XCTAssertEqual(Statistics.roundToDecimalplaces(123.4567898972239487234), 123.46) + XCTAssertEqual(Statistics.roundToDecimalPlaces(123.4567898972239487234), 123.46) XCTAssertEqual(stats.measurementCount, measurementCount * 2) XCTAssertEqual(stats.units(), .count) XCTAssertEqual(round(stats.histogram.mean), round(Double(measurementCount / 2)))