-
Notifications
You must be signed in to change notification settings - Fork 263
[SR-1355] Add Performance Measurement APIs #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
// This source file is part of the Swift.org open source project | ||
// | ||
// Copyright (c) 2016 Apple Inc. and the Swift project authors | ||
// Licensed under Apache License v2.0 with Runtime Library Exception | ||
// | ||
// See http://swift.org/LICENSE.txt for license information | ||
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors | ||
// | ||
// | ||
// PerformanceMeter.swift | ||
// Measures the performance of a block of code and reports the results. | ||
// | ||
|
||
#if os(Linux) || os(FreeBSD) | ||
import Foundation | ||
#else | ||
import SwiftFoundation | ||
#endif | ||
|
||
/// Describes a type that is capable of measuring some aspect of code performance | ||
/// over time. | ||
internal protocol PerformanceMetric { | ||
/// Called once per iteration immediately before the tested code is executed. | ||
/// The metric should do whatever work is required to begin a new measurement. | ||
func startMeasuring() | ||
|
||
/// Called once per iteration immediately after the tested code is executed. | ||
/// The metric should do whatever work is required to finalize measurement. | ||
func stopMeasuring() | ||
|
||
/// Called once, after all measurements have been taken, to provide feedback | ||
/// about the collected measurements. | ||
/// - Returns: Measurement results to present to the user. | ||
func calculateResults() -> String | ||
|
||
/// Called once, after all measurements have been taken, to determine whether | ||
/// the measurements should be treated as a test failure or not. | ||
/// - Returns: A diagnostic message if the results indicate failure, else nil. | ||
func failureMessage() -> String? | ||
} | ||
|
||
internal protocol PerformanceMeterDelegate { | ||
func recordMeasurements(results: String, file: StaticString, line: UInt) | ||
func recordFailure(description: String, file: StaticString, line: UInt) | ||
func recordAPIViolation(description: String, file: StaticString, line: UInt) | ||
} | ||
|
||
/// - Bug: This class is intended to be `internal` but is public to work around | ||
/// a toolchain bug on Linux. See `XCTestCase._performanceMeter` for more info. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
public final class PerformanceMeter { | ||
enum Error: ErrorProtocol, CustomStringConvertible { | ||
case noMetrics | ||
case unknownMetric(metricName: String) | ||
case startMeasuringAlreadyCalled | ||
case stopMeasuringAlreadyCalled | ||
case startMeasuringNotCalled | ||
case stopBeforeStarting | ||
|
||
var description: String { | ||
switch self { | ||
case .noMetrics: return "At least one metric must be provided to measure." | ||
case .unknownMetric(let name): return "Unknown metric: \(name)" | ||
case .startMeasuringAlreadyCalled: return "Already called startMeasuring() once this iteration." | ||
case .stopMeasuringAlreadyCalled: return "Already called stopMeasuring() once this iteration." | ||
case .startMeasuringNotCalled: return "startMeasuring() must be called during the block." | ||
case .stopBeforeStarting: return "Cannot stop measuring before starting measuring." | ||
} | ||
} | ||
} | ||
|
||
internal var didFinishMeasuring: Bool { | ||
return state == .measurementFinished || state == .measurementAborted | ||
} | ||
|
||
private enum State { | ||
case iterationUnstarted | ||
case iterationStarted | ||
case iterationFinished | ||
case measurementFinished | ||
case measurementAborted | ||
} | ||
private var state: State = .iterationUnstarted | ||
|
||
private let metrics: [PerformanceMetric] | ||
private let delegate: PerformanceMeterDelegate | ||
private let invocationFile: StaticString | ||
private let invocationLine: UInt | ||
|
||
private init(metrics: [PerformanceMetric], delegate: PerformanceMeterDelegate, file: StaticString, line: UInt) { | ||
self.metrics = metrics | ||
self.delegate = delegate | ||
self.invocationFile = file | ||
self.invocationLine = line | ||
} | ||
|
||
static func measureMetrics(_ metricNames: [String], delegate: PerformanceMeterDelegate, file: StaticString = #file, line: UInt = #line, for block: @noescape (PerformanceMeter) -> Void) { | ||
do { | ||
let metrics = try self.metrics(forNames: metricNames) | ||
let meter = PerformanceMeter(metrics: metrics, delegate: delegate, file: file, line: line) | ||
meter.measure(block) | ||
} catch let e { | ||
delegate.recordAPIViolation(description: String(e), file: file, line: line) | ||
} | ||
} | ||
|
||
func startMeasuring(file: StaticString = #file, line: UInt = #line) { | ||
guard state == .iterationUnstarted else { | ||
state = .measurementAborted | ||
return recordAPIViolation(.startMeasuringAlreadyCalled, file: file, line: line) | ||
} | ||
state = .iterationStarted | ||
metrics.forEach { $0.startMeasuring() } | ||
} | ||
|
||
func stopMeasuring(file: StaticString = #file, line: UInt = #line) { | ||
guard state != .iterationUnstarted else { | ||
return recordAPIViolation(.stopBeforeStarting, file: file, line: line) | ||
} | ||
|
||
guard state != .iterationFinished else { | ||
return recordAPIViolation(.stopMeasuringAlreadyCalled, file: file, line: line) | ||
} | ||
|
||
state = .iterationFinished | ||
metrics.forEach { $0.stopMeasuring() } | ||
} | ||
|
||
func abortMeasuring() { | ||
state = .measurementAborted | ||
} | ||
|
||
|
||
private static func metrics(forNames names: [String]) throws -> [PerformanceMetric] { | ||
guard !names.isEmpty else { throw Error.noMetrics } | ||
|
||
let metricsMapping = [WallClockTimeMetric.name : WallClockTimeMetric.self] | ||
|
||
return try names.map({ | ||
guard let metricType = metricsMapping[$0] else { throw Error.unknownMetric(metricName: $0) } | ||
return metricType.init() | ||
}) | ||
} | ||
|
||
private var numberOfIterations: Int { | ||
return 10 | ||
} | ||
|
||
private func measure(_ block: @noescape (PerformanceMeter) -> Void) { | ||
for _ in (0..<numberOfIterations) { | ||
state = .iterationUnstarted | ||
|
||
block(self) | ||
stopMeasuringIfNeeded() | ||
|
||
if state == .measurementAborted { return } | ||
|
||
if state == .iterationUnstarted { | ||
recordAPIViolation(.startMeasuringNotCalled, file: invocationFile, line: invocationLine) | ||
return | ||
} | ||
} | ||
state = .measurementFinished | ||
|
||
recordResults() | ||
recordFailures() | ||
} | ||
|
||
private func stopMeasuringIfNeeded() { | ||
if state == .iterationStarted { | ||
stopMeasuring() | ||
} | ||
} | ||
|
||
private func recordResults() { | ||
for metric in metrics { | ||
delegate.recordMeasurements(results: metric.calculateResults(), file: invocationFile, line: invocationLine) | ||
} | ||
} | ||
|
||
private func recordFailures() { | ||
metrics.flatMap({ $0.failureMessage() }).forEach { message in | ||
delegate.recordFailure(description: message, file: invocationFile, line: invocationLine) | ||
} | ||
} | ||
|
||
private func recordAPIViolation(_ error: Error, file: StaticString, line: UInt) { | ||
state = .measurementAborted | ||
delegate.recordAPIViolation(description: String(error), file: file, line: line) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
// This source file is part of the Swift.org open source project | ||
// | ||
// Copyright (c) 2016 Apple Inc. and the Swift project authors | ||
// Licensed under Apache License v2.0 with Runtime Library Exception | ||
// | ||
// See http://swift.org/LICENSE.txt for license information | ||
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors | ||
// | ||
// | ||
// WallClockTimeMetric.swift | ||
// Performance metric measuring how long it takes code to execute | ||
// | ||
|
||
#if os(Linux) || os(FreeBSD) | ||
import Foundation | ||
#else | ||
import SwiftFoundation | ||
#endif | ||
|
||
/// This metric uses the system uptime to keep track of how much time passes | ||
/// between starting and stopping measuring. | ||
internal final class WallClockTimeMetric: PerformanceMetric { | ||
static let name = "org.swift.XCTPerformanceMetric_WallClockTime" | ||
|
||
typealias Measurement = NSTimeInterval | ||
private var startTime: NSTimeInterval? | ||
var measurements: [Measurement] = [] | ||
|
||
func startMeasuring() { | ||
startTime = currentTime() | ||
} | ||
|
||
func stopMeasuring() { | ||
guard let startTime = startTime else { fatalError("Must start measuring before stopping measuring") } | ||
let stopTime = currentTime() | ||
measurements.append(stopTime-startTime) | ||
} | ||
|
||
private let maxRelativeStandardDeviation = 10.0 | ||
private let standardDeviationNegligibilityThreshold = 0.1 | ||
|
||
func calculateResults() -> String { | ||
let results = [ | ||
String(format: "average: %.3f", measurements.average), | ||
String(format: "relative standard deviation: %.3f%%", measurements.relativeStandardDeviation), | ||
"values: [\(measurements.map({ String(format: "%.6f", $0) }).joined(separator: ", "))]", | ||
"performanceMetricID:\(self.dynamicType.name)", | ||
String(format: "maxPercentRelativeStandardDeviation: %.3f%%", maxRelativeStandardDeviation), | ||
String(format: "maxStandardDeviation: %.3f", standardDeviationNegligibilityThreshold), | ||
] | ||
return "[Time, seconds] \(results.joined(separator: ", "))" | ||
} | ||
|
||
func failureMessage() -> String? { | ||
// The relative standard deviation of the measurements is \d+.\d{3}% which is higher than the max allowed of \d+.\d{3}%. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit-pick: I feel like this comment doesn't add much--my eyes only need to scan a few lines down to see this method either returns a standard deviation failure, or no failure at all. Just my two cents. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ooooops, that line wan't supposed to be included here, good catch. 😆 I had copied it in from the test file for reference when I was writing the method. |
||
let relativeStandardDeviation = measurements.relativeStandardDeviation | ||
if (relativeStandardDeviation > maxRelativeStandardDeviation && | ||
measurements.standardDeviation > standardDeviationNegligibilityThreshold) { | ||
return String(format: "The relative standard deviation of the measurements is %.3f%% which is higher than the max allowed of %.3f%%.", relativeStandardDeviation, maxRelativeStandardDeviation) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
private func currentTime() -> NSTimeInterval { | ||
return NSProcessInfo.processInfo().systemUptime | ||
} | ||
} | ||
|
||
|
||
private extension Collection where Index: IntegerLiteralConvertible, Iterator.Element == WallClockTimeMetric.Measurement { | ||
var average: WallClockTimeMetric.Measurement { | ||
return self.reduce(0, combine: +) / Double(count.toIntMax()) | ||
} | ||
|
||
var standardDeviation: WallClockTimeMetric.Measurement { | ||
let average = self.average | ||
let squaredDifferences = self.map({ pow($0 - average, 2.0) }) | ||
let variance = squaredDifferences.reduce(0, combine: +) / Double(count.toIntMax()-1) | ||
return sqrt(variance) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Calculating the standard deviation almost seems like something that could be in the stdlib. Or, once we get unit tests set up thanks to #61, the logic here could be extracted into its own function and unit tested. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, that wasn't super clear: this is a nit-pick. Prior to actually having unit tests, I don't feel strongly about whether this should be more generic than it already is. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I'm looking forward to getting unit tests on this code at some point. |
||
|
||
var relativeStandardDeviation: Double { | ||
return (standardDeviation*100) / average | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The awesome documentation for
PerformanceMetric
spoiled me, now I wish there was documentation here, too 😝There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call!