Skip to content

Commit df27b99

Browse files
committed
Gate startup performance across target sizes
Signed-off-by: irl-dan <97565471+irl-dan@users.noreply.github.com>
1 parent 1e79781 commit df27b99

10 files changed

Lines changed: 303 additions & 18 deletions

File tree

.github/workflows/performance.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Startup performance
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: performance-${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
startup:
18+
name: macOS startup envelope
19+
runs-on: macos-15
20+
timeout-minutes: 20
21+
env:
22+
MARGIN_STARTUP_RUNS: 10
23+
MARGIN_STARTUP_WARMUPS: 3
24+
MARGIN_STARTUP_VISIBLE_P95_LIMIT_MS: 1000
25+
MARGIN_STARTUP_READY_P95_LIMIT_MS: 3000
26+
steps:
27+
- name: Check out source
28+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29+
30+
- name: Build release app
31+
run: make release
32+
33+
- name: Measure startup matrix
34+
run: Scripts/benchmark-startup-matrix.sh
35+
36+
- name: Upload measurements
37+
if: always()
38+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
39+
with:
40+
name: startup-performance-${{ github.sha }}
41+
path: build/benchmarks/startup-matrix/
42+
if-no-files-found: error
43+
retention-days: 90

Benchmarks/performance/LaunchBenchmark.swift

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ private struct Options {
2727
var warmups = 3
2828
var settleMilliseconds = 250
2929
var timeoutMilliseconds = 5_000
30+
var visibleP95LimitMilliseconds: Double?
31+
var readyP95LimitMilliseconds: Double?
3032

3133
init(arguments: [String]) throws {
3234
var index = 0
@@ -48,14 +50,18 @@ private struct Options {
4850
case "--warmups": warmups = try Self.nonnegativeInteger(try value(after: option), option: option)
4951
case "--settle-ms": settleMilliseconds = try Self.nonnegativeInteger(try value(after: option), option: option)
5052
case "--timeout-ms": timeoutMilliseconds = try Self.positiveInteger(try value(after: option), option: option)
53+
case "--visible-p95-limit-ms":
54+
visibleP95LimitMilliseconds = try Self.positiveDouble(try value(after: option), option: option)
55+
case "--ready-p95-limit-ms":
56+
readyP95LimitMilliseconds = try Self.positiveDouble(try value(after: option), option: option)
5157
default: throw BenchmarkFailure.usage("Unknown option \(option).")
5258
}
5359
index += 2
5460
}
5561

5662
guard !appPath.isEmpty, !documentPath.isEmpty, !outputPath.isEmpty else {
5763
throw BenchmarkFailure.usage(
58-
"usage: launch-benchmark --app APP --document FILE --output JSON [--runs N] [--warmups N]"
64+
"usage: launch-benchmark --app APP --document FILE_OR_DIRECTORY --output JSON [--runs N] [--warmups N] [--visible-p95-limit-ms N] [--ready-p95-limit-ms N]"
5965
)
6066
}
6167
}
@@ -73,10 +79,18 @@ private struct Options {
7379
}
7480
return value
7581
}
82+
83+
private static func positiveDouble(_ raw: String, option: String) throws -> Double {
84+
guard let value = Double(raw), value.isFinite, value > 0 else {
85+
throw BenchmarkFailure.invalidValue("\(option) expects a positive number.")
86+
}
87+
return value
88+
}
7689
}
7790

7891
private struct Sample {
7992
let launchMilliseconds: Double
93+
let readyMilliseconds: Double
8094
let residentMemoryMiB: Double
8195
}
8296

@@ -137,15 +151,18 @@ private struct BenchmarkReport: Encodable {
137151
let settleMilliseconds: Int
138152
let timeoutMilliseconds: Int
139153
let documentPath: String
154+
let visibleP95LimitMilliseconds: Double?
155+
let readyP95LimitMilliseconds: Double?
140156
}
141157

142-
let schema = "urn:margin:performance:v1"
158+
let schema = "urn:margin:performance:v2"
143159
let measuredAt: String
144160
let measurement: String
145161
let system: System
146162
let artifact: Artifact
147163
let settings: Settings
148164
let launchMilliseconds: Statistics
165+
let readyMilliseconds: Statistics
149166
let residentMemoryMiB: Statistics
150167
}
151168

@@ -204,6 +221,11 @@ private func measureOnce(options: Options) throws -> Sample {
204221
let process = Process()
205222
process.executableURL = executable
206223
process.arguments = [options.documentPath]
224+
let readyURL = FileManager.default.temporaryDirectory
225+
.appendingPathComponent("margin-ready-\(UUID().uuidString)", isDirectory: false)
226+
process.environment = ProcessInfo.processInfo.environment.merging([
227+
"MARGIN_BENCHMARK_READY_FILE": readyURL.path,
228+
]) { _, benchmarkValue in benchmarkValue }
207229
process.standardOutput = FileHandle.nullDevice
208230
process.standardError = FileHandle.nullDevice
209231

@@ -213,20 +235,34 @@ private func measureOnce(options: Options) throws -> Sample {
213235
} catch {
214236
throw BenchmarkFailure.launch("Could not launch \(executable.path): \(error.localizedDescription)")
215237
}
216-
defer { stop(process) }
238+
defer {
239+
stop(process)
240+
try? FileManager.default.removeItem(at: readyURL)
241+
}
217242

218243
let timeout = UInt64(options.timeoutMilliseconds) * 1_000_000
219244
var visibleAt: UInt64?
245+
var readyAt: UInt64?
220246
while process.isRunning && DispatchTime.now().uptimeNanoseconds - start < timeout {
221-
if isWindowVisible(for: process.processIdentifier) {
247+
if visibleAt == nil, isWindowVisible(for: process.processIdentifier) {
222248
visibleAt = DispatchTime.now().uptimeNanoseconds
249+
}
250+
if readyAt == nil, FileManager.default.fileExists(atPath: readyURL.path) {
251+
readyAt = DispatchTime.now().uptimeNanoseconds
252+
}
253+
if visibleAt != nil, readyAt != nil {
223254
break
224255
}
225256
usleep(1_000)
226257
}
227258
guard let visibleAt else {
228259
throw BenchmarkFailure.timeout(options.timeoutMilliseconds)
229260
}
261+
guard let readyAt else {
262+
throw BenchmarkFailure.measurement(
263+
"Margin showed a window but did not finish loading the benchmark target within \(options.timeoutMilliseconds) ms."
264+
)
265+
}
230266

231267
if options.settleMilliseconds > 0 {
232268
usleep(useconds_t(options.settleMilliseconds * 1_000))
@@ -237,6 +273,7 @@ private func measureOnce(options: Options) throws -> Sample {
237273
let residentMiB = try residentMemoryMiB(for: process.processIdentifier)
238274
return Sample(
239275
launchMilliseconds: Double(visibleAt - start) / 1_000_000,
276+
readyMilliseconds: Double(readyAt - start) / 1_000_000,
240277
residentMemoryMiB: residentMiB
241278
)
242279
}
@@ -302,7 +339,7 @@ do {
302339
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
303340
let report = BenchmarkReport(
304341
measuredAt: formatter.string(from: Date()),
305-
measurement: "Warm direct-executable spawn to first on-screen layer-0 app window; RSS sampled after the settle interval.",
342+
measurement: "Warm direct-executable spawn to first on-screen layer-0 app window and to completion of target loading and initial Markdown presentation; RSS sampled after the settle interval.",
306343
system: .init(
307344
operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString,
308345
architecture: architecture,
@@ -319,9 +356,12 @@ do {
319356
warmupRuns: options.warmups,
320357
settleMilliseconds: options.settleMilliseconds,
321358
timeoutMilliseconds: options.timeoutMilliseconds,
322-
documentPath: URL(fileURLWithPath: options.documentPath).standardizedFileURL.path
359+
documentPath: URL(fileURLWithPath: options.documentPath).standardizedFileURL.path,
360+
visibleP95LimitMilliseconds: options.visibleP95LimitMilliseconds,
361+
readyP95LimitMilliseconds: options.readyP95LimitMilliseconds
323362
),
324363
launchMilliseconds: Statistics(samples.map(\.launchMilliseconds)),
364+
readyMilliseconds: Statistics(samples.map(\.readyMilliseconds)),
325365
residentMemoryMiB: Statistics(samples.map(\.residentMemoryMiB))
326366
)
327367

@@ -336,6 +376,17 @@ do {
336376
)
337377
try data.write(to: outputURL, options: .atomic)
338378
FileHandle.standardOutput.write(data)
379+
380+
if let limit = options.visibleP95LimitMilliseconds, report.launchMilliseconds.p95 > limit {
381+
throw BenchmarkFailure.measurement(
382+
"Visible-window p95 \(report.launchMilliseconds.p95) ms exceeds the \(limit) ms limit."
383+
)
384+
}
385+
if let limit = options.readyP95LimitMilliseconds, report.readyMilliseconds.p95 > limit {
386+
throw BenchmarkFailure.measurement(
387+
"Target-ready p95 \(report.readyMilliseconds.p95) ms exceeds the \(limit) ms limit."
388+
)
389+
}
339390
} catch {
340391
let message = (error as? BenchmarkFailure)?.description ?? error.localizedDescription
341392
fputs("launch-benchmark: \(message)\n", stderr)

CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ opening a pull request.
6464
| AppKit UI or app behavior | `make test`, `make smoke` |
6565
| Comment or collaboration protocol | `make test`, `make test-linux`, `make eval` |
6666
| Directory transactions or agent workflow | `make eval-collaboration` |
67-
| Launch-path or packaging behavior | `make smoke`, `make benchmark`, `make package` |
67+
| Launch-path behavior | `make smoke`, `make benchmark-matrix` |
68+
| Packaging behavior | `make package` |
6869
| MarginBench implementation or public evidence | `make marginbench-test`, `make marginbench-preflight` |
6970

7071
`make eval-preflight` and paid or remote benchmark runs are not routine pull

Docs/PERFORMANCE.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ make benchmark
1111
The default run performs three cache warm-ups and fifteen measured launches of the packaged release app. It records:
1212

1313
- launch latency from spawning `Margin.app/Contents/MacOS/Margin` until its first on-screen layer-0 window is visible;
14+
- target-ready latency until the requested Markdown has been decoded, installed in the real editor with its initial syntax presentation, and made editable when permissions allow;
1415
- resident set size (RSS) 250 ms after that window appears;
1516
- logical app-bundle bytes and main-executable bytes.
1617

17-
Results are written to `build/benchmarks/performance.json`. The launch value is an external window-visible proxy: it is reproducible without modifying production code, but it does not claim to measure the final compositor frame or every asynchronous document-loading operation.
18+
Results are written to `build/benchmarks/performance.json`. The visible-window value is an external proxy and does not claim to measure the final compositor frame. The target-ready value comes from an environment-gated marker emitted after initial document presentation and editor activation; ordinary app launches do not create the marker or perform benchmark I/O.
19+
20+
Run the release-sized file and directory matrix with:
21+
22+
```sh
23+
make benchmark-matrix
24+
```
25+
26+
That command covers 4 KiB, 1 MiB, and 5 MiB Markdown files plus directories with 100 and 10,000 entries. A directory result includes root enumeration, initial `README.md` selection, document decoding, and initial Markdown presentation. The checked-in local p95 limits are 500 ms for the first visible window and 1,250 ms for target readiness.
1827

1928
Override the sample size or input when investigating regressions:
2029

@@ -27,6 +36,14 @@ make benchmark
2736

2837
For clean comparisons, close other Margin instances, use the same hardware and power state, and compare release bundles built with the same macOS toolchain.
2938

39+
## What GitHub Actions can establish
40+
41+
The `Startup performance` workflow runs the complete matrix on one `macos-15` runner, publishes the table in the job summary, retains the raw JSON samples for 90 days, and fails when any case exceeds a 1,000 ms visible-window p95 or 3,000 ms target-ready p95. Keeping all cases in one job makes size comparisons share the same host and machine state.
42+
43+
Those intentionally loose hosted-runner limits are regression alarms, not end-user guarantees. GitHub currently documents the standard public `macos-15` runner as an arm64 M1 VM with three CPUs and 7 GB RAM, but hosted runner load, virtualization, image revisions, thermal state, storage caches, and end-user hardware are outside Margin's control. A hardware-specific service-level guarantee would require a controlled physical or dedicated self-hosted Mac, a pinned OS and toolchain, repeated cold and warm samples, and ongoing calibration. See GitHub's [hosted-runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) and [self-hosted runner guidance](https://docs.github.com/en/actions/concepts/runners/self-hosted-runners).
44+
45+
Margin therefore does **not** promise universal sub-200 ms startup. The current AppKit baseline and Margin measurements do not support that claim. The public statement is narrower: the release is continuously checked against the documented CI envelope, and the reference-machine measurements below are reproducible evidence for a specific system.
46+
3047
## Current reference and framework floor
3148

3249
The following reference run used an Apple M1 Max on macOS 26.2, the release bundle, three warm-ups, fifteen measured launches, and the same external window-visible/RSS probe for every row.
@@ -132,3 +149,22 @@ Across 100 fresh CLI processes after three warmups, ordinary help measured
132149
/ 8.477 ms**. The corresponding structured outputs are 12,716 and 19,797
133150
bytes, both below their hard bounds and independent of filesystem or network
134151
state.
152+
153+
## v0.4.0 startup matrix baseline
154+
155+
The v0.4.0 release candidate completed the new matrix on an Apple M1 Max with
156+
macOS 26.2. Every case used three warm-ups and ten measured direct launches.
157+
158+
| Case | Visible median | Visible p95 | Ready median | Ready p95 |
159+
|---|---:|---:|---:|---:|
160+
| 4 KiB Markdown file | 323.903 ms | 403.134 ms | 354.102 ms | 442.868 ms |
161+
| 1 MiB Markdown file | 317.479 ms | 343.534 ms | 470.048 ms | 496.853 ms |
162+
| 5 MiB Markdown file | 319.724 ms | 366.418 ms | 920.244 ms | 983.171 ms |
163+
| Directory with 100 entries | 323.939 ms | 332.526 ms | 513.149 ms | 521.528 ms |
164+
| Directory with 10,000 entries | 320.360 ms | 327.736 ms | 1,030.775 ms | 1,060.048 ms |
165+
166+
The nearly flat visible-window results confirm that file decoding and directory
167+
enumeration do not block the first window. Target readiness scales with the
168+
requested work and remains within the checked-in 1,250 ms local p95 envelope
169+
for this matrix. These warm-launch figures support “a few hundred milliseconds
170+
to a visible window” on the reference system, not a sub-200 ms claim.

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ BUILD_SCRATCH_PATH ?= $(SCRATCH_ROOT)/command-line-tools
66
TEST_SCRATCH_PATH ?= $(SCRATCH_ROOT)/xcode-15.4
77
OUTPUT_DIR ?= $(PROJECT_DIR)/build
88

9-
.PHONY: debug test test-linux check-version marginbench-test marginbench-audit marginbench-preflight marginbench-control-preflight marginbench-neutral-preflight marginbench-remote-plan marginbench-linux-binary marginbench-package release package package-linux installer install smoke benchmark eval eval-preflight eval-collaboration clean
9+
.PHONY: debug test test-linux check-version marginbench-test marginbench-audit marginbench-preflight marginbench-control-preflight marginbench-neutral-preflight marginbench-remote-plan marginbench-linux-binary marginbench-package release package package-linux installer install smoke benchmark benchmark-matrix eval eval-preflight eval-collaboration clean
1010

1111
check-version:
1212
"$(PROJECT_DIR)/Scripts/check-version.sh"
@@ -155,6 +155,12 @@ benchmark: release
155155
MARGIN_PERFORMANCE_SCRATCH_PATH="$(PROJECT_DIR)/.build/performance" \
156156
"$(PROJECT_DIR)/Scripts/benchmark-performance.sh"
157157

158+
benchmark-matrix: release
159+
DEVELOPER_DIR="$(BUILD_DEVELOPER_DIR)" \
160+
MARGIN_BUILD_OUTPUT_DIR="$(OUTPUT_DIR)" \
161+
MARGIN_PERFORMANCE_SCRATCH_PATH="$(PROJECT_DIR)/.build/performance" \
162+
"$(PROJECT_DIR)/Scripts/benchmark-startup-matrix.sh"
163+
158164
eval: release
159165
PYTHONDONTWRITEBYTECODE=1 \
160166
python3 -m unittest discover -s "$(PROJECT_DIR)/Evals/cli/tests" -p 'test_*.py'

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ directions rather than promises or dates.
2020
Apple Developer credentials.
2121
- Improve first-run installation and update guidance without adding a daemon or
2222
background service.
23+
- Investigate whether the real editable-window path can be brought below 200 ms
24+
on the reference Mac. The v0.4.0 baseline is intentionally documented and
25+
gated above that target; do not advertise sub-200 ms until repeatable evidence
26+
supports it.
2327
- Add a restrained product screenshot and social preview to the public project.
2428
- Expand private MarginBench validation before making any general performance
2529
or agent-effectiveness claim.

Scripts/benchmark-performance.sh

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ SETTLE_MS="${MARGIN_BENCHMARK_SETTLE_MS:-250}"
1111
TIMEOUT_MS="${MARGIN_BENCHMARK_TIMEOUT_MS:-5000}"
1212
SCRATCH_PATH="${MARGIN_PERFORMANCE_SCRATCH_PATH:-$PROJECT_DIR/.build/performance}"
1313
RESULTS_PATH="${MARGIN_BENCHMARK_RESULTS:-$OUTPUT_DIR/benchmarks/performance.json}"
14+
VISIBLE_P95_LIMIT_MS="${MARGIN_BENCHMARK_VISIBLE_P95_LIMIT_MS:-}"
15+
READY_P95_LIMIT_MS="${MARGIN_BENCHMARK_READY_P95_LIMIT_MS:-}"
1416

15-
if [[ ! -x "$APP_BUNDLE/Contents/MacOS/Margin" || ! -f "$DOCUMENT" ]]; then
16-
print -u2 "Margin.app or benchmark document is missing. Run make release first."
17+
if [[ ! -x "$APP_BUNDLE/Contents/MacOS/Margin" || ! -e "$DOCUMENT" ]]; then
18+
print -u2 "Margin.app or benchmark target is missing. Run make release first."
1719
exit 66
1820
fi
1921

@@ -26,14 +28,19 @@ mkdir -p "$SCRATCH_PATH" "${RESULTS_PATH:h}"
2628
RUNNER="$SCRATCH_PATH/launch-benchmark"
2729
xcrun swiftc -O "$PROJECT_DIR/Benchmarks/performance/LaunchBenchmark.swift" -o "$RUNNER"
2830

29-
"$RUNNER" \
30-
--app "$APP_BUNDLE" \
31-
--document "$DOCUMENT" \
32-
--runs "$RUNS" \
33-
--warmups "$WARMUPS" \
34-
--settle-ms "$SETTLE_MS" \
35-
--timeout-ms "$TIMEOUT_MS" \
31+
ARGS=(
32+
--app "$APP_BUNDLE"
33+
--document "$DOCUMENT"
34+
--runs "$RUNS"
35+
--warmups "$WARMUPS"
36+
--settle-ms "$SETTLE_MS"
37+
--timeout-ms "$TIMEOUT_MS"
3638
--output "$RESULTS_PATH"
39+
)
40+
[[ -n "$VISIBLE_P95_LIMIT_MS" ]] && ARGS+=(--visible-p95-limit-ms "$VISIBLE_P95_LIMIT_MS")
41+
[[ -n "$READY_P95_LIMIT_MS" ]] && ARGS+=(--ready-p95-limit-ms "$READY_P95_LIMIT_MS")
42+
43+
"$RUNNER" "${ARGS[@]}"
3744

3845
print "Results: $RESULTS_PATH"
3946
print "Allocated bundle size (KiB): $(/usr/bin/du -sk "$APP_BUNDLE" | /usr/bin/awk '{print $1}')"

0 commit comments

Comments
 (0)