From 996e753e2a16c82e0d2fb677e863ae69f42a3317 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 7 Jul 2026 15:00:38 -0500 Subject: [PATCH 1/3] perf: wire launch-to-interactive CI budget; verify updateForState deopt still needed Measurement-driven perf pass for 1.1.49 release prep (PRD WS4.2). Launch-to-interactive budget (new) - The launch UI smoke already proves the menu bar is interactive but had no timing. Add launchToInteractiveMs to the smoke report, measured from the kernel process-start time (kinfo_proc.p_starttime) so it captures true launch latency including pre-main dyld/runtime setup. - Score it in performance-budget.rb via --launch-ui-smoke / --max-launch-interactive-ms and enforce in CI's Premium performance budget step (extends the existing budget; no parallel harness). build.sh scores it locally too. - Measured on Apple Silicon: warm ~285ms (meets PRD <400ms warm target), cold ~600ms, fresh-build cold ~900ms. CI ceiling set to 1500ms as a regression guard with headroom for a cold GitHub runner. updateForState forced-deopt (verified, kept) - Re-tested removing @_optimize(none) under the current toolchain (swiftlang-6.3.3.1.3 / Swift 6.3.3): the identical SimplifyCFG / BorrowUtils.swift:542 compiler crash still reproduces. Workaround still required. Confirmed it is not a hot path (fires only on discrete overlay state transitions, not per audio frame), so the deopt costs nothing. Refreshed the comment with the re-verification so nobody re-tests blindly. No behavior change. App size 88.3 MiB (budget 220), resources 2.0 MiB (budget 80), Home recent-captures 20.6ms@1k / 214.9ms@10k avg (budget 750) all unchanged & green. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/swift-ci.yml | 7 +++- Sources/TranscriptedApp.swift | 25 ++++++++++++ .../UI/MenuBar/MenuBarPanelController.swift | 9 ++++- Sources/UI/Overlay/OverlayRootView.swift | 14 +++++-- scripts/entrypoints/build.sh | 7 ++++ scripts/ops/performance-budget.rb | 38 +++++++++++++++++++ 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 1924e8a1..9f9f00af 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -227,7 +227,12 @@ jobs: - name: Premium performance budget shell: bash - run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 + # The app-build step above runs build.sh, whose launch smoke writes + # build/launch-ui-smoke.json with launchToInteractiveMs. Score it here so + # a launch-to-interactive regression fails CI (PRD WS4.2). The ceiling is + # a generous regression guard over the sub-400ms warm product target, + # leaving headroom for a cold GitHub runner. + run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 1500 # --------------------------------------------------------------------------- # Required-status umbrella. Branch protection requires the "build-and-test" diff --git a/Sources/TranscriptedApp.swift b/Sources/TranscriptedApp.swift index eceb86b1..30cc6392 100644 --- a/Sources/TranscriptedApp.swift +++ b/Sources/TranscriptedApp.swift @@ -891,6 +891,7 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat statusItemExists: statusItem != nil, popoverConfigured: popover != nil, onboardingCompleted: true, + launchToInteractiveMs: Self.processStartToNowMilliseconds(), menuVisibilityOverride: smokeMenuVisibility ) let reportURL = URL(fileURLWithPath: reportPath, isDirectory: false) @@ -908,6 +909,30 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat } } + /// Wall-clock milliseconds from the kernel's process-start time to now. + /// Measured from `kinfo_proc.p_starttime` so it captures the true + /// launch-to-interactive latency including pre-`main` dyld/runtime setup, + /// not just the time since the app delegate began running. Returns nil if + /// the syscall fails or the clock reads backwards. CI-smoke only. + private static func processStartToNowMilliseconds() -> Double? { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var info = kinfo_proc() + var size = MemoryLayout.stride + let status = mib.withUnsafeMutableBufferPointer { buffer in + sysctl(buffer.baseAddress, UInt32(buffer.count), &info, &size, nil, 0) + } + guard status == 0 else { return nil } + + var now = timeval() + guard gettimeofday(&now, nil) == 0 else { return nil } + + let start = info.kp_proc.p_starttime + let startMs = Double(start.tv_sec) * 1000.0 + Double(start.tv_usec) / 1000.0 + let nowMs = Double(now.tv_sec) * 1000.0 + Double(now.tv_usec) / 1000.0 + let elapsed = nowMs - startMs + return elapsed >= 0 ? elapsed : nil + } + private func scheduleLaunchUISmokeTerminationIfRequested(environment: [String: String]) { guard environment["TRANSCRIPTED_LAUNCH_UI_SMOKE_TERMINATE_AFTER_REPORT"] == "1" else { return } diff --git a/Sources/UI/MenuBar/MenuBarPanelController.swift b/Sources/UI/MenuBar/MenuBarPanelController.swift index d5e6d641..27547334 100644 --- a/Sources/UI/MenuBar/MenuBarPanelController.swift +++ b/Sources/UI/MenuBar/MenuBarPanelController.swift @@ -10,6 +10,11 @@ struct MenuBarLaunchUISmokeReport: Codable, Equatable { let popoverConfigured: Bool let onboardingCompleted: Bool let content: MenuBarContentSmokeSnapshot + /// Wall-clock from kernel process-start to the moment the menu-bar UI is + /// interactive (status item + popover configured). Powers the CI + /// launch-to-interactive perf budget (PRD WS4.2). Optional so older report + /// consumers and hand-built fixtures stay decodable. + var launchToInteractiveMs: Double? } @MainActor @@ -158,6 +163,7 @@ final class MenuBarPanelController: NSViewController { statusItemExists: Bool, popoverConfigured: Bool, onboardingCompleted: Bool, + launchToInteractiveMs: Double? = nil, menuVisibilityOverride: [MenuBarOptionalItem: Bool] ) -> MenuBarLaunchUISmokeReport { loadViewIfNeeded() @@ -184,7 +190,8 @@ final class MenuBarPanelController: NSViewController { ), primaryActions: [:], utilityActions: [:] - ) + ), + launchToInteractiveMs: launchToInteractiveMs ) } diff --git a/Sources/UI/Overlay/OverlayRootView.swift b/Sources/UI/Overlay/OverlayRootView.swift index 2fe7a467..51d7da1f 100644 --- a/Sources/UI/Overlay/OverlayRootView.swift +++ b/Sources/UI/Overlay/OverlayRootView.swift @@ -115,10 +115,16 @@ final class OverlayRootView: NSView { // MARK: - State Updates // @_optimize(none): the -O SimplifyCFG pass crashes on this function's - // parameter/closure shape under swiftlang-6.3.2.1.108 (SIL/BorrowUtils.swift:542, - // "cannot get borrow introducers for unknown guaranteed value"). This is a - // compiler bug, not a correctness issue — disable optimization here until upstream - // fixes it. Reproduced in CI at github.com/r3dbars/transcripted actions run 28685559441. + // parameter/closure shape (SIL/BorrowUtils.swift:542, "cannot get borrow + // introducers for unknown guaranteed value"). This is a compiler bug, not a + // correctness issue — disable optimization here until upstream fixes it. + // First seen under swiftlang-6.3.2.1.108 (CI actions run 28685559441). + // Re-verified STILL CRASHING under swiftlang-6.3.3.1.3 (Swift 6.3.3) on + // 2026-07-07: removing this attribute reproduces the identical SimplifyCFG + // fault. Keep it. Perf cost is nil: `updateForState` runs only on discrete + // overlay state transitions (see FloatingOverlayController.pushStateToViews), + // never per audio frame — the hot meter path calls headerView.updateWaveformLevel + // directly, so this function is not on any hot path. @_optimize(none) func updateForState( _ state: FloatingOverlayController.OverlayState, diff --git a/scripts/entrypoints/build.sh b/scripts/entrypoints/build.sh index 61e0030c..d1cd1fc6 100755 --- a/scripts/entrypoints/build.sh +++ b/scripts/entrypoints/build.sh @@ -617,6 +617,13 @@ PERFORMANCE_BUDGET_ARGS=(--app "$APP_BUNDLE") if [ "$BUNDLE_PARAKEET_MODELS" = "0" ]; then PERFORMANCE_BUDGET_ARGS+=(--allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80) fi +# The launch smoke (above) writes the interactive-readiness report; score its +# launch-to-interactive latency when it ran. Skipped smoke → no report → skip +# the budget rather than fail on a file that was intentionally not produced. +LAUNCH_UI_SMOKE_REPORT="$REPO_ROOT/$BUILD_DIR/launch-ui-smoke.json" +if [ "${TRANSCRIPTED_SKIP_LAUNCH_SMOKE:-0}" != "1" ] && [ -s "$LAUNCH_UI_SMOKE_REPORT" ]; then + PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 1500) +fi scripts/ops/performance-budget.rb "${PERFORMANCE_BUDGET_ARGS[@]}" echo "Build complete!" diff --git a/scripts/ops/performance-budget.rb b/scripts/ops/performance-budget.rb index 636ebb06..db27c908 100755 --- a/scripts/ops/performance-budget.rb +++ b/scripts/ops/performance-budget.rb @@ -24,6 +24,12 @@ MAX_MEETING_P95_RTF = 0.05 MAX_HOME_RECENT_CAPTURE_AVERAGE_LOAD_MS = 750.0 MAX_HOME_RECENT_CAPTURE_CANCEL_MS = 100.0 +# Launch-to-interactive ceiling (kernel process-start -> menu-bar UI ready), +# read from the launch UI smoke report. PRD WS4.2 product target is <400ms warm +# on Apple Silicon; this regression ceiling carries headroom for slower/cold CI +# runners while still catching a multi-x blow-up. Callers pass an explicit +# --max-launch-interactive-ms tuned to the surface (dev build vs CI runner). +MAX_LAUNCH_TO_INTERACTIVE_MS = 1_500.0 MIN_MEETING_DURATION_SECONDS = 30.0 MIN_TRANSCRIPTION_SAMPLES = 10 MIN_STARTUP_SAMPLES = 3 @@ -60,6 +66,8 @@ check_home_recent_captures: false, max_home_recent_capture_average_load_ms: MAX_HOME_RECENT_CAPTURE_AVERAGE_LOAD_MS, max_home_recent_capture_cancel_ms: MAX_HOME_RECENT_CAPTURE_CANCEL_MS, + launch_ui_smoke_path: nil, + max_launch_to_interactive_ms: MAX_LAUNCH_TO_INTERACTIVE_MS, require_launch_model_ready_samples: 0, require_dictation_fast_start_samples: 0, require_dictation_stop_latency_samples: 0, @@ -90,6 +98,8 @@ parser.on("--check-home-recent-captures", "Run deterministic Home recent-captures loader budget") { options[:check_home_recent_captures] = true } parser.on("--max-home-recent-capture-average-load-ms MS", Float, "Home recent-captures average load budget") { |ms| options[:max_home_recent_capture_average_load_ms] = ms } parser.on("--max-home-recent-capture-cancel-ms MS", Float, "Home recent-captures cancellation acknowledgement budget") { |ms| options[:max_home_recent_capture_cancel_ms] = ms } + parser.on("--launch-ui-smoke PATH", "Launch UI smoke report JSON for the launch-to-interactive budget") { |path| options[:launch_ui_smoke_path] = path } + parser.on("--max-launch-interactive-ms MS", Float, "Launch-to-interactive budget in ms (kernel process-start to menu-bar UI ready)") { |ms| options[:max_launch_to_interactive_ms] = ms } parser.on("--require-launch-model-ready-samples N", Integer, "Require at least N launch-to-model-ready samples in --events logs") { |count| options[:require_launch_model_ready_samples] = count } parser.on("--require-dictation-fast-start-samples N", Integer, "Require at least N fast-start samples in --events logs") { |count| options[:require_dictation_fast_start_samples] = count } parser.on("--require-dictation-stop-latency-samples N", Integer, "Require at least N stop-latency samples in --events logs") { |count| options[:require_dictation_stop_latency_samples] = count } @@ -477,6 +487,30 @@ def run_home_recent_capture_benchmark(max_average_load_ms:, max_cancel_ms:) end end +launch_interactive_summary = nil +if options[:launch_ui_smoke_path] + smoke_path = Pathname.new(options[:launch_ui_smoke_path]).expand_path + if !smoke_path.file? + errors << "Missing launch UI smoke report: #{smoke_path}" + else + begin + report = JSON.parse(File.read(smoke_path)) + value = float_or_nil(report["launchToInteractiveMs"]) + budget = options[:max_launch_to_interactive_ms] + if value.nil? + errors << "Launch UI smoke report #{smoke_path} is missing launchToInteractiveMs" + else + launch_interactive_summary = { value_ms: value, budget_ms: budget } + if value > budget + errors << "Launch to interactive is #{format("%.1fms", value)}, above #{format("%.1fms", budget)}" + end + end + rescue JSON::ParserError => error + errors << "Launch UI smoke report #{smoke_path} is not valid JSON: #{error.message}" + end + end +end + fail_budget!(errors) puts "Performance budget OK" @@ -555,3 +589,7 @@ def run_home_recent_capture_benchmark(max_average_load_ms:, max_cancel_ms:) puts "Home recent-captures benchmark:" puts home_recent_capture_summary[:stdout].strip end +if launch_interactive_summary + puts "Launch to interactive: #{format("%.1fms", launch_interactive_summary[:value_ms])} " \ + "(budget #{format("%.1fms", launch_interactive_summary[:budget_ms])})" +end From e21f44ee1bddb1d75bcc3e880fd92dfc7326941b Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 7 Jul 2026 15:08:19 -0500 Subject: [PATCH 2/3] perf: raise launch-to-interactive CI ceiling to 2500ms (runner headroom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CI run measured cold launch-to-interactive at 1420ms on the GitHub macos-26 runner — only 80ms (5%) under the initial 1500ms ceiling, which would flake under normal runner variance. Raise to 2500ms: still a firm regression guard (fails on a ~1.8x+ blow-up from the 1420ms cold baseline / ~285ms warm product number) but no longer flaky. A perf gate that flakes is worse than a loose one. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/swift-ci.yml | 9 +++++---- scripts/entrypoints/build.sh | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 9f9f00af..1664bc4b 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -229,10 +229,11 @@ jobs: shell: bash # The app-build step above runs build.sh, whose launch smoke writes # build/launch-ui-smoke.json with launchToInteractiveMs. Score it here so - # a launch-to-interactive regression fails CI (PRD WS4.2). The ceiling is - # a generous regression guard over the sub-400ms warm product target, - # leaving headroom for a cold GitHub runner. - run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 1500 + # a launch-to-interactive regression fails CI (PRD WS4.2). Product target + # is <400ms warm on Apple Silicon (measured ~285ms); a cold GitHub runner + # measured ~1420ms, so the ceiling is 2500ms — a regression guard with + # room for runner variance that still fails on a ~1.8x+ blow-up. + run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 2500 # --------------------------------------------------------------------------- # Required-status umbrella. Branch protection requires the "build-and-test" diff --git a/scripts/entrypoints/build.sh b/scripts/entrypoints/build.sh index d1cd1fc6..6f6a2138 100755 --- a/scripts/entrypoints/build.sh +++ b/scripts/entrypoints/build.sh @@ -622,7 +622,7 @@ fi # the budget rather than fail on a file that was intentionally not produced. LAUNCH_UI_SMOKE_REPORT="$REPO_ROOT/$BUILD_DIR/launch-ui-smoke.json" if [ "${TRANSCRIPTED_SKIP_LAUNCH_SMOKE:-0}" != "1" ] && [ -s "$LAUNCH_UI_SMOKE_REPORT" ]; then - PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 1500) + PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 2500) fi scripts/ops/performance-budget.rb "${PERFORMANCE_BUDGET_ARGS[@]}" From c315e7e880e6b48c9ccab56c91d1a3d84ac08475 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 7 Jul 2026 15:18:09 -0500 Subject: [PATCH 3/3] perf: set launch-to-interactive CI ceiling to 3000ms (coarse guard, no flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI runs on identical commits measured cold launch-to-interactive at 1420ms and 2095ms — a ~48% run-to-run swing on the GitHub runner's single cold sample. At 2500ms the 2095ms sample sits only 16% under the ceiling, still flake-prone. Set 3000ms: the CI gate is explicitly a coarse catastrophic-regression guard (the real UX number is the ~285ms warm Apple-Silicon product measurement, not this noisy cold CI sample). Documented min-of-N smoke sampling as the future signal-sharpening follow-up. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/swift-ci.yml | 14 +++++++++----- scripts/entrypoints/build.sh | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 1664bc4b..3483eb9a 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -229,11 +229,15 @@ jobs: shell: bash # The app-build step above runs build.sh, whose launch smoke writes # build/launch-ui-smoke.json with launchToInteractiveMs. Score it here so - # a launch-to-interactive regression fails CI (PRD WS4.2). Product target - # is <400ms warm on Apple Silicon (measured ~285ms); a cold GitHub runner - # measured ~1420ms, so the ceiling is 2500ms — a regression guard with - # room for runner variance that still fails on a ~1.8x+ blow-up. - run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 2500 + # a launch-to-interactive regression fails CI (PRD WS4.2). The real UX + # number is the warm Apple-Silicon product target (<400ms, measured + # ~285ms). This CI gate is a COARSE catastrophic-regression guard only: + # the GitHub runner's single cold-launch sample is very noisy (observed + # 1420ms and 2095ms on identical commits, ~48% swing), so 3000ms is set + # to never flake while still failing on a gross blow-up (e.g. a + # synchronous model/network load added to the launch path). A future + # tightening is min-of-N sampling in the smoke to sharpen the signal. + run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 3000 # --------------------------------------------------------------------------- # Required-status umbrella. Branch protection requires the "build-and-test" diff --git a/scripts/entrypoints/build.sh b/scripts/entrypoints/build.sh index 6f6a2138..cef2e66a 100755 --- a/scripts/entrypoints/build.sh +++ b/scripts/entrypoints/build.sh @@ -622,7 +622,7 @@ fi # the budget rather than fail on a file that was intentionally not produced. LAUNCH_UI_SMOKE_REPORT="$REPO_ROOT/$BUILD_DIR/launch-ui-smoke.json" if [ "${TRANSCRIPTED_SKIP_LAUNCH_SMOKE:-0}" != "1" ] && [ -s "$LAUNCH_UI_SMOKE_REPORT" ]; then - PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 2500) + PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 3000) fi scripts/ops/performance-budget.rb "${PERFORMANCE_BUDGET_ARGS[@]}"