Skip to content

Commit d1c8500

Browse files
authored
Add performance benchmarks (#721)
* Add performance benchmarks for crash capture operations Add XCTest-based benchmarks for core KSCrash components: - KSBacktrace: Stack capture and symbolication - KSMemory: Safe memory operations - KSJSONCodec: JSON encoding for crash reports - KSThread: Thread enumeration and info gathering Includes GitHub Actions workflow that: - Runs benchmarks on PRs and master pushes - Compares PR performance against base branch - Posts formatted results as PR comments - Shows per-operation timing with status indicators * Update KSCrash-Package.xcscheme * Update KSBacktraceBenchmarks.swift * Update benchmarks.yml
1 parent 77dd04d commit d1c8500

File tree

7 files changed

+1120
-0
lines changed

7 files changed

+1120
-0
lines changed

.github/workflows/benchmarks.yml

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
name: Benchmarks
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "Sources/**"
7+
- "Tests/KSCrashBenchmarks/**"
8+
- "Package.swift"
9+
- ".github/workflows/benchmarks.yml"
10+
11+
push:
12+
branches:
13+
- master
14+
15+
workflow_dispatch:
16+
17+
schedule:
18+
- cron: "0 0 * * 0"
19+
20+
permissions:
21+
contents: read
22+
pull-requests: write
23+
24+
concurrency:
25+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
benchmark:
30+
runs-on: macos-latest
31+
32+
steps:
33+
- name: Checkout PR Branch
34+
uses: actions/checkout@v4
35+
with:
36+
path: pr-branch
37+
38+
- name: Checkout Base Branch
39+
if: github.event_name == 'pull_request'
40+
uses: actions/checkout@v4
41+
with:
42+
ref: ${{ github.base_ref }}
43+
path: base-branch
44+
45+
- name: Use Latest Stable Xcode
46+
uses: maxim-lobanov/setup-xcode@v1
47+
with:
48+
xcode-version: latest-stable
49+
50+
- name: Run PR Benchmarks
51+
working-directory: pr-branch
52+
run: |
53+
swift test --filter KSCrashBenchmarks 2>&1 | tee ../pr_benchmark_output.txt
54+
55+
- name: Run Base Benchmarks
56+
if: github.event_name == 'pull_request'
57+
working-directory: base-branch
58+
run: |
59+
# Check if benchmarks exist in base branch
60+
if [ -d "Tests/KSCrashBenchmarks" ] && grep -q "KSCrashBenchmarks" Package.swift; then
61+
swift test --filter KSCrashBenchmarks 2>&1 | tee ../base_benchmark_output.txt
62+
else
63+
echo "No benchmarks in base branch" > ../base_benchmark_output.txt
64+
fi
65+
66+
- name: Parse and Format Results
67+
env:
68+
IS_PR: ${{ github.event_name == 'pull_request' }}
69+
run: |
70+
python3 << 'EOF'
71+
import re
72+
import os
73+
from datetime import datetime
74+
import subprocess
75+
76+
is_pr = os.environ.get('IS_PR', 'false') == 'true'
77+
78+
# Get system info
79+
try:
80+
chip = subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().strip()
81+
except:
82+
chip = subprocess.check_output(['uname', '-m']).decode().strip()
83+
84+
try:
85+
mem_bytes = int(subprocess.check_output(['sysctl', '-n', 'hw.memsize']).decode().strip())
86+
mem_gb = mem_bytes / (1024**3)
87+
except:
88+
mem_gb = 0
89+
90+
try:
91+
macos_ver = subprocess.check_output(['sw_vers', '-productVersion']).decode().strip()
92+
except:
93+
macos_ver = "Unknown"
94+
95+
try:
96+
xcode_ver = subprocess.check_output(['xcodebuild', '-version']).decode().split('\n')[0]
97+
except:
98+
xcode_ver = "Unknown"
99+
100+
system_info = f"{chip}, {mem_gb:.0f}GB RAM, macOS {macos_ver}, {xcode_ver}"
101+
102+
def parse_results(filename):
103+
"""Parse benchmark output file and return dict of results"""
104+
try:
105+
with open(filename, 'r') as f:
106+
content = f.read()
107+
if "No benchmarks in base branch" in content:
108+
return None
109+
pattern = r"testBenchmark(\w+).*?average: ([\d.]+)"
110+
matches = re.findall(pattern, content)
111+
return {name: float(time) for name, time in matches}
112+
except FileNotFoundError:
113+
return None
114+
115+
# Read PR benchmark output
116+
pr_results = parse_results('pr_benchmark_output.txt')
117+
base_results = parse_results('base_benchmark_output.txt') if is_pr else None
118+
119+
with open('pr_benchmark_output.txt', 'r') as f:
120+
pr_content = f.read()
121+
122+
def format_time(seconds):
123+
"""Format time in human-readable units"""
124+
us = seconds * 1_000_000
125+
if us < 1:
126+
return "<1 μs"
127+
elif us < 1000:
128+
return f"{us:.1f} μs"
129+
else:
130+
return f"{us/1000:.2f} ms"
131+
132+
def get_status(seconds, excellent=0.001, good=0.005, ok=0.010):
133+
"""Get status emoji based on time thresholds"""
134+
if seconds < excellent:
135+
return "✅ Excellent"
136+
elif seconds < good:
137+
return "✅ Good"
138+
elif seconds < ok:
139+
return "⚠️ OK"
140+
else:
141+
return "❌ Review"
142+
143+
def get_change(base, pr):
144+
"""Calculate percentage change and return formatted string"""
145+
if base == 0:
146+
return "N/A"
147+
pct = ((pr - base) / base) * 100
148+
if pct > 10:
149+
return f"⚠️ +{pct:.1f}%"
150+
elif pct < -10:
151+
return f"✅ {pct:.1f}%"
152+
else:
153+
return f"{pct:+.1f}%"
154+
155+
# Start markdown output
156+
output = ["# 🔍 KSCrash Performance Benchmarks\n"]
157+
output.append("*Crash capture performance metrics - lower times are better*\n")
158+
output.append(f"**Test Environment:** {system_info}\n")
159+
160+
# All test definitions
161+
all_tests = {
162+
"SameThreadBacktrace": "Same thread capture",
163+
"OtherThreadBacktrace": "Cross-thread capture",
164+
"BacktraceTypicalDepth": "Typical depth (50 frames)",
165+
"SymbolicateSingleAddress": "Symbolicate (1 address)",
166+
"SymbolicateFullStack": "Symbolicate (20 frames)",
167+
"CaptureAndSymbolicate": "Capture + symbolicate",
168+
"IsMemoryReadableSmall": "Check readable (64B)",
169+
"IsMemoryReadablePage": "Check readable (4KB)",
170+
"IsMemoryReadableLarge": "Check readable (64KB)",
171+
"CopySafelySmall": "Safe copy (64B)",
172+
"CopySafelyPage": "Safe copy (4KB)",
173+
"CopySafelyLarge": "Safe copy (32KB)",
174+
"CopyMaxPossible": "Copy max possible",
175+
"MaxReadableBytesValid": "Find max readable",
176+
"TypicalCrashMemoryDump": "Typical crash dump",
177+
"EncodeIntegers": "Encode integers",
178+
"EncodeFloats": "Encode floats",
179+
"EncodeBooleans": "Encode booleans",
180+
"EncodeShortStrings": "Encode short strings",
181+
"EncodeLongStrings": "Encode long strings",
182+
"EncodeStringsWithEscaping": "Encode escaped strings",
183+
"EncodeNestedObjects": "Encode nested objects",
184+
"EncodeArrays": "Encode arrays",
185+
"EncodeHexData": "Encode hex data",
186+
"EncodeTypicalCrashReport": "Full crash report",
187+
"ThreadSelf": "Get current thread",
188+
"GetThreadName": "Get thread name",
189+
"GetThreadState": "Get thread state",
190+
"ThreadStateName": "State to string",
191+
"GetQueueName": "Get queue name",
192+
"GatherThreadInfo": "Gather all thread info",
193+
"ThreadOpsWithConcurrency": "Ops under contention",
194+
}
195+
196+
# Comparison section (only for PRs with base results)
197+
if is_pr and base_results:
198+
output.append("## 📊 Changes from Base Branch\n")
199+
output.append("| Operation | Base | PR | Change |")
200+
output.append("|-----------|------|-----|--------|")
201+
202+
changes = []
203+
for test_name, desc in all_tests.items():
204+
if test_name in pr_results and test_name in base_results:
205+
base_t = base_results[test_name]
206+
pr_t = pr_results[test_name]
207+
change = get_change(base_t, pr_t)
208+
pct = ((pr_t - base_t) / base_t) * 100 if base_t > 0 else 0
209+
changes.append((abs(pct), f"| {desc} | {format_time(base_t)} | {format_time(pr_t)} | {change} |"))
210+
211+
# Sort by absolute change, show top changes
212+
changes.sort(reverse=True)
213+
for _, row in changes[:15]: # Show top 15 changes
214+
output.append(row)
215+
216+
if len(changes) > 15:
217+
output.append(f"\n*Showing top 15 of {len(changes)} benchmarks by change magnitude*")
218+
output.append("")
219+
elif is_pr and not base_results:
220+
output.append("## 📊 Changes from Base Branch\n")
221+
output.append("*No benchmarks in base branch - showing PR results only*\n")
222+
223+
# Backtrace section
224+
output.append("## Stack Capture (KSBacktrace)\n")
225+
output.append("| Operation | Time | Status | Notes |")
226+
output.append("|-----------|------|--------|-------|")
227+
228+
backtrace_tests = {
229+
"SameThreadBacktrace": ("Same thread capture", "Fast path, no suspension"),
230+
"OtherThreadBacktrace": ("Cross-thread capture", "Requires thread suspension"),
231+
"BacktraceTypicalDepth": ("Typical depth (50 frames)", "Common crash scenario"),
232+
"SymbolicateSingleAddress": ("Symbolicate (1 address)", "Single frame lookup"),
233+
"SymbolicateFullStack": ("Symbolicate (20 frames)", "Full stack resolution"),
234+
"CaptureAndSymbolicate": ("Capture + symbolicate", "Complete crash flow"),
235+
}
236+
237+
for test_name, (desc, notes) in backtrace_tests.items():
238+
if test_name in pr_results:
239+
t = pr_results[test_name]
240+
status = get_status(t, excellent=0.0001, good=0.001, ok=0.005)
241+
output.append(f"| {desc} | {format_time(t)} | {status} | {notes} |")
242+
243+
# Memory section
244+
output.append("\n## Safe Memory Operations (KSMemory)\n")
245+
output.append("| Operation | Time | Per-Call | Status |")
246+
output.append("|-----------|------|----------|--------|")
247+
248+
memory_tests = {
249+
"IsMemoryReadableSmall": ("Check readable (64B)", 1000),
250+
"IsMemoryReadablePage": ("Check readable (4KB)", 1000),
251+
"IsMemoryReadableLarge": ("Check readable (64KB)", 100),
252+
"CopySafelySmall": ("Safe copy (64B)", 1000),
253+
"CopySafelyPage": ("Safe copy (4KB)", 1000),
254+
"CopySafelyLarge": ("Safe copy (32KB)", 100),
255+
"CopyMaxPossible": ("Copy max possible (4KB)", 100),
256+
"MaxReadableBytesValid": ("Find max readable", 100),
257+
"TypicalCrashMemoryDump": ("Typical crash dump (8KB)", 1),
258+
}
259+
260+
for test_name, (desc, iterations) in memory_tests.items():
261+
if test_name in pr_results:
262+
t = pr_results[test_name]
263+
per_call = t / iterations
264+
status = get_status(per_call, excellent=0.000001, good=0.00001, ok=0.0001)
265+
output.append(f"| {desc} | {format_time(t)} | {format_time(per_call)} | {status} |")
266+
267+
# JSON section
268+
output.append("\n## JSON Encoding (KSJSONCodec)\n")
269+
output.append("| Operation | Time | Status | Notes |")
270+
output.append("|-----------|------|--------|-------|")
271+
272+
json_tests = {
273+
"EncodeIntegers": ("Encode integers (100)", "Numeric fields"),
274+
"EncodeFloats": ("Encode floats (100)", "Floating point"),
275+
"EncodeBooleans": ("Encode booleans (100)", "Boolean fields"),
276+
"EncodeShortStrings": ("Encode short strings (100)", "~13 chars each"),
277+
"EncodeLongStrings": ("Encode long strings (50)", "~520 chars each"),
278+
"EncodeStringsWithEscaping": ("Encode escaped strings (100)", "Special chars"),
279+
"EncodeNestedObjects": ("Encode nested objects (20)", "Thread-like structure"),
280+
"EncodeArrays": ("Encode arrays (50 items)", "Backtrace addresses"),
281+
"EncodeHexData": ("Encode hex data (10×256B)", "Memory dumps"),
282+
"EncodeTypicalCrashReport": ("Full crash report", "10 threads, 30 frames each"),
283+
}
284+
285+
for test_name, (desc, notes) in json_tests.items():
286+
if test_name in pr_results:
287+
t = pr_results[test_name]
288+
status = get_status(t, excellent=0.0005, good=0.002, ok=0.010)
289+
output.append(f"| {desc} | {format_time(t)} | {status} | {notes} |")
290+
291+
# Thread section
292+
output.append("\n## Thread Operations (KSThread)\n")
293+
output.append("| Operation | Time | Per-Call | Status |")
294+
output.append("|-----------|------|----------|--------|")
295+
296+
thread_tests = {
297+
"ThreadSelf": ("Get current thread", 10000),
298+
"GetThreadName": ("Get thread name", 1000),
299+
"GetThreadState": ("Get thread state", 1000),
300+
"ThreadStateName": ("State to string", 10000),
301+
"GetQueueName": ("Get queue name", 1000),
302+
"GatherThreadInfo": ("Gather all thread info", 100),
303+
"ThreadOpsWithConcurrency": ("Ops under contention", 1000),
304+
}
305+
306+
for test_name, (desc, iterations) in thread_tests.items():
307+
if test_name in pr_results:
308+
t = pr_results[test_name]
309+
per_call = t / iterations
310+
status = get_status(per_call, excellent=0.0000005, good=0.000005, ok=0.00001)
311+
output.append(f"| {desc} | {format_time(t)} | {format_time(per_call)} | {status} |")
312+
313+
# Performance guide
314+
output.append("\n## Performance Guidelines")
315+
output.append("### Crash Capture Budget")
316+
output.append("- ✅ **Excellent**: Operations complete in microseconds")
317+
output.append("- ✅ **Good**: Acceptable for crash-time execution")
318+
output.append("- ⚠️ **OK**: May impact crash capture latency")
319+
output.append("- ❌ **Review**: Too slow for crash-time, consider optimization")
320+
output.append("\n*Goal: Complete crash capture <100ms to minimize data loss risk*")
321+
322+
# Summary
323+
total_tests = len(pr_results) if pr_results else 0
324+
passed = len(re.findall(r"Test Case.*passed", pr_content))
325+
output.append(f"\n---\n**{total_tests} benchmarks** | {passed} tests passed | _Generated {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}_")
326+
327+
# Write to file
328+
with open('benchmark_results.md', 'w') as f:
329+
f.write('\n'.join(output))
330+
331+
print('\n'.join(output))
332+
EOF
333+
334+
- name: Comment PR with Results
335+
if: github.event_name == 'pull_request'
336+
uses: thollander/actions-comment-pull-request@v2
337+
with:
338+
filePath: benchmark_results.md
339+
comment_tag: benchmark-results

.swiftpm/xcode/xcshareddata/xcschemes/KSCrash-Package.xcscheme

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,16 @@
238238
ReferencedContainer = "container:">
239239
</BuildableReference>
240240
</TestableReference>
241+
<TestableReference
242+
skipped = "NO">
243+
<BuildableReference
244+
BuildableIdentifier = "primary"
245+
BlueprintIdentifier = "KSCrashBenchmarks"
246+
BuildableName = "KSCrashBenchmarks"
247+
BlueprintName = "KSCrashBenchmarks"
248+
ReferencedContainer = "container:">
249+
</BuildableReference>
250+
</TestableReference>
241251
</Testables>
242252
</TestAction>
243253
<LaunchAction

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,14 @@ let package = Package(
496496
.unsafeFlags(warningFlags)
497497
]
498498
),
499+
500+
.testTarget(
501+
name: Targets.benchmarks,
502+
dependencies: [
503+
.target(name: Targets.recordingCore),
504+
.target(name: Targets.recording),
505+
]
506+
),
499507
],
500508
cxxLanguageStandard: .gnucxx11
501509
)
@@ -513,6 +521,7 @@ enum Targets {
513521
static let bootTimeMonitor = "KSCrashBootTimeMonitor"
514522
static let demangleFilter = "KSCrashDemangleFilter"
515523
static let testTools = "KSCrashTestTools"
524+
static let benchmarks = "KSCrashBenchmarks"
516525
}
517526

518527
extension String {

0 commit comments

Comments
 (0)