Skip to content

Commit 889306f

Browse files
committed
Add workflow to test the performance of a package
This adds the infrastructure to take performance measurements for PRs by taking a performance measurement of the project before the changes in the PR. This will become really useful once we can switch this to a macOS runner and are then able to measure instructions executed by an executable (like swift-format supports using `swift-format --measure-instructions`) – instruction counting isn’t available on Linux as far as I could find out. But even now, we can use this to track other metrics like binary size.
1 parent 416e29a commit 889306f

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-0
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
name: Performance test
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
container:
7+
type: string
8+
description: "The container that the performance tests should run in"
9+
default: "swift:latest"
10+
pre_measure_command:
11+
type: string
12+
description: "The command that should be executed before running the performance measurements, eg. to build the project."
13+
required: true
14+
measure_command:
15+
type: string
16+
description: |
17+
The command that should be executed to run the performance measurements.
18+
19+
The output of this command can produce multiple performance measurements. Each measurement should be on a
20+
separate line with the name of the measurement on the left side of a colon and the measurement on the right
21+
side of the colon.
22+
23+
For example, this could be
24+
```
25+
Instructions executed for test case A: 123456789
26+
Instructions executed for test case B: 2345678
27+
Code Size: 34567
28+
```
29+
required: true
30+
sensitivity:
31+
type: number
32+
description: |
33+
The percentage after which a change should be considered meaningful.
34+
Eg. specify 0.5 to add a comment to the PR if any measurement changed by more than 0.5% (either improved or regressed).
35+
default: 0.5
36+
comment_header:
37+
type: string
38+
description: |
39+
If the performance has changed, this text will be prepended to the comment that contains the performance measurements.
40+
This can be either for performance improvements or regressions.
41+
default: |
42+
This PR has changed performance characteristics. Please review that the measurements reported below are expected. If these are improvements, thanks for improving the performance.
43+
44+
jobs:
45+
measure_performance:
46+
name: Measure performance
47+
runs-on: ubuntu-latest
48+
container:
49+
image: ${{ inputs.container }}
50+
timeout-minutes: 60
51+
outputs:
52+
has_significant_changes: ${{ steps.analyze_performance.outputs.has_significant_changes }}
53+
comment_body: ${{ steps.analyze_performance.outputs.comment_body }}
54+
steps:
55+
- name: Checkout repository
56+
uses: actions/checkout@v4
57+
with:
58+
persist-credentials: false
59+
submodules: true
60+
fetch-depth: 0
61+
- name: Mark the workspace as safe
62+
# https://github.com/actions/checkout/issues/766
63+
run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
64+
- name: Measure performance with changes
65+
id: pr_performance
66+
run: |
67+
${{ inputs.pre_measure_command }}
68+
{
69+
echo 'output<<EOF'
70+
${{ inputs.measure_command }}
71+
echo EOF
72+
} >> "$GITHUB_OUTPUT"
73+
- name: Measure baseline performance
74+
id: baseline_performance
75+
run: |
76+
git checkout "${{ github.base_ref }}"
77+
${{ inputs.pre_measure_command }}
78+
{
79+
echo 'output<<EOF'
80+
${{ inputs.measure_command }}
81+
echo EOF
82+
} >> "$GITHUB_OUTPUT"
83+
- name: Download performance analysis script
84+
run: |
85+
apt -q update
86+
apt -yq install curl
87+
curl -s https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/compare-performance-measurements.swift > /tmp/compare-performance-measurements.swift
88+
- name: Analyze performance
89+
id: analyze_performance
90+
shell: bash
91+
run: |
92+
if ! OUTPUT=$(swift /tmp/compare-performance-measurements.swift "${{ steps.baseline_performance.outputs.output }}" "${{ steps.pr_performance.outputs.output }}" "${{ inputs.sensitivity }}"); then
93+
echo "has_significant_changes=true" >> "$GITHUB_OUTPUT"
94+
else
95+
echo "has_significant_changes=false" >> "$GITHUB_OUTPUT"
96+
fi
97+
{
98+
echo 'comment_body<<EOF'
99+
echo "$OUTPUT"
100+
echo EOF
101+
} >> "$GITHUB_OUTPUT"
102+
post_performance_report:
103+
name: Post comment if performance changed
104+
runs-on: ubuntu-latest
105+
needs: [measure_performance]
106+
if: ${{ needs.measure_performance.outputs.has_significant_changes == 'true' }}
107+
timeout-minutes: 5
108+
steps:
109+
- name: Checkout repository
110+
uses: actions/checkout@v4
111+
- name: Post comment to GitHub
112+
env:
113+
GH_TOKEN: ${{ github.token }}
114+
run: |
115+
COMMENT=$(
116+
cat <<END_OF_COMMENT
117+
${{ inputs.comment_header }}
118+
119+
${{ needs.measure_performance.outputs.comment_body }}
120+
END_OF_COMMENT
121+
)
122+
123+
124+
gh pr comment ${{ github.event.number }} --body "$COMMENT"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
15+
func printToStderr(_ value: String) {
16+
fputs(value, stderr)
17+
}
18+
19+
extension Double {
20+
func round(toDecimalDigits decimalDigits: Int) -> Double {
21+
return (self * pow(10, Double(decimalDigits))).rounded() / pow(10, Double(decimalDigits))
22+
}
23+
}
24+
25+
/// Given a performance measurement output, extract the actual measurement into a dictionary.
26+
///
27+
/// We expect every measurement to be on its own line with the name of the measurement on the left side of a colon and
28+
/// the measurement on the right side of the colon.
29+
///
30+
/// For example, the output could be:
31+
/// ```
32+
/// Instructions executed for test case A: 123456789
33+
/// Instructions executed for test case B: 2345678
34+
/// Code Size: 34567
35+
/// ```
36+
func extractMeasurements(output: String) -> [String: Double] {
37+
var measurements: [String: Double] = [:]
38+
for line in output.split(separator: "\n") {
39+
guard let colonPosition = line.lastIndex(of: ":") else {
40+
printToStderr(
41+
"Ignoring following measurement line because it doesn't contain a colon: \(line)"
42+
)
43+
continue
44+
}
45+
let beforeColon = String(line[..<colonPosition]).trimmingCharacters(in: .whitespacesAndNewlines)
46+
let afterColon = String(line[line.index(after: colonPosition)...]).trimmingCharacters(
47+
in: .whitespacesAndNewlines
48+
)
49+
guard let value = Double(afterColon) else {
50+
printToStderr(
51+
"Ignoring following measurement line because the value can't be parsed as a Double: \(line)"
52+
)
53+
continue
54+
}
55+
measurements[beforeColon] = value
56+
}
57+
return measurements
58+
}
59+
60+
func run(
61+
baselinePerformanceOutput: String,
62+
changedPerformanceOutput: String,
63+
sensitivityPercentage: Double
64+
) -> (output: String, hasDetectedSignificantChange: Bool) {
65+
let baselineMeasurements = extractMeasurements(output: baselinePerformanceOutput)
66+
let changedMeasurements = extractMeasurements(output: changedPerformanceOutput)
67+
68+
var hasDetectedSignificantChange = false
69+
var output = ""
70+
for (measurementName, baselineValue) in baselineMeasurements.sorted(by: { $0.key < $1.key }) {
71+
guard let changedValue = changedMeasurements[measurementName] else {
72+
output += "🛑 \(measurementName) not present after changes\n"
73+
continue
74+
}
75+
let differencePercentage = (changedValue - baselineValue) / baselineValue * 100
76+
let rawMeasurementsText = "(baseline: \(baselineValue), after changes: \(changedValue))"
77+
if differencePercentage < -sensitivityPercentage {
78+
output +=
79+
"🎉 \(measurementName) improved by \(-differencePercentage.round(toDecimalDigits: 3))% \(rawMeasurementsText)\n"
80+
hasDetectedSignificantChange = true
81+
} else if differencePercentage > sensitivityPercentage {
82+
output +=
83+
"⚠️ \(measurementName) regressed by \(differencePercentage.round(toDecimalDigits: 3))% \(rawMeasurementsText)\n"
84+
hasDetectedSignificantChange = true
85+
} else {
86+
output +=
87+
"➡️ \(measurementName) did not change significantly with \(differencePercentage.round(toDecimalDigits: 3))% \(rawMeasurementsText)\n"
88+
}
89+
}
90+
return (output, hasDetectedSignificantChange)
91+
}
92+
93+
guard CommandLine.arguments.count > 3 else {
94+
printToStderr(
95+
"""
96+
Usage: compare-performance-measurements.swift <BASELINE> <WITH_CHANGES> <SENSITIVITY>
97+
98+
Where BASELINE and WITH_CHANGES are strings containing the performance measurements, with each measurement on a
99+
separate line with the name of the measurement on the left side of a colon and the measurement on the right side of
100+
the colon.
101+
102+
For example, the baseline could be.
103+
```
104+
Instructions executed for test case A: 123456789
105+
Instructions executed for test case B: 2345678
106+
Code Size: 34567
107+
```
108+
109+
Sensitivity is the percentage after which a change should be considered meaningful. Eg. specify 0.5 to report a
110+
significant performance change if any of the measurements changed by more than 0.5% (either improved or regressed).
111+
"""
112+
)
113+
exit(1)
114+
}
115+
116+
let baselinePerformanceOutput = CommandLine.arguments[1]
117+
let changedPerformanceOutput = CommandLine.arguments[2]
118+
let sensitivityPercentage = Double(CommandLine.arguments[3])
119+
120+
guard let sensitivityPercentage else {
121+
printToStderr("Sensitivity was not a valid Double value")
122+
exit(1)
123+
}
124+
125+
let (output, hasDetectedSignificantChange) = run(
126+
baselinePerformanceOutput: baselinePerformanceOutput,
127+
changedPerformanceOutput: changedPerformanceOutput,
128+
sensitivityPercentage: sensitivityPercentage
129+
)
130+
131+
print(output)
132+
if hasDetectedSignificantChange {
133+
exit(1)
134+
}

.swift-format

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"version": 1,
3+
"lineLength": 120,
4+
"indentation": {
5+
"spaces": 2
6+
},
7+
"lineBreakBeforeEachArgument": true,
8+
"indentConditionalCompilationBlocks": false,
9+
"prioritizeKeepingFunctionOutputTogether": true,
10+
"rules": {
11+
"AlwaysUseLowerCamelCase": false,
12+
"AmbiguousTrailingClosureOverload": false,
13+
"NoBlockComments": false,
14+
"OrderedImports": true,
15+
"UseLetInEveryBoundCaseVariable": false,
16+
"UseSynthesizedInitializer": false
17+
}
18+
}

0 commit comments

Comments
 (0)