|
| 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 |
0 commit comments