diff --git a/.gitignore b/.gitignore index 21ae049..688c666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Finder/iCloud duplicate copies ("name 2.ext") +* 2.md +* 2.png + +# Generated performance fixtures and measurement output +perf/results/ +perf/fixtures/*.mp4 +perf/fixtures/*.mov +perf/fixtures/*.m4v +perf/fixtures/*.hevc + .DS_Store .claude/worktrees/ /.build diff --git a/CHANGELOG.md b/CHANGELOG.md index acaef4f..b4c1048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to Lively are documented here. The format follows [Keep a Ch - New suites for update checking (stubbed network), video validation with generated H.264 and Motion-JPEG clips, config persistence/migration, preferences, Reset Data, and system suspension. - New CI workflow runs build, tests, and packaging on every push and pull request. +- Performance harness: `os_signpost` intervals, `scripts/perf/measure.sh`, generated fixtures. No runtime behaviour change unless `LIVELY_PERF_MODE` is set. +- Measure isolation: `LIVELY_APPLICATION_SUPPORT` and `LIVELY_CONFIG_FILE` redirect Application Support. Setting `HOME` does not move `userDomainMask` on current macOS. ## [1.2.0] - 2026-07-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc79007..475bf41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ swift build # build swift test # run tests (full Xcode) ./scripts/check_hygiene.sh # paths, secrets, duplicates, plist and script syntax ./package.sh # build an ad-hoc signed Lively.app (output in $LIVELY_OUTPUT_DIR) +scripts/perf/measure.sh idle 30 # isolated Application Support sample; see scripts/perf/README.md ``` `swiftlint` (config in `.swiftlint.yml`) runs in CI; run it locally if installed. diff --git a/Sources/Lively/Core/WallpaperController.swift b/Sources/Lively/Core/WallpaperController.swift index 197a7a7..da3e0c0 100644 --- a/Sources/Lively/Core/WallpaperController.swift +++ b/Sources/Lively/Core/WallpaperController.swift @@ -83,7 +83,9 @@ private final class WallpaperSession { currentURL = url failedURL = nil - loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError) + PerfSignposts.interval(PerfSignposts.playerBuild) { + loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError) + } } private func loadVideo(url: URL, muted: Bool, volume: Float, onError: @escaping @Sendable (Error) -> Void) { @@ -215,13 +217,17 @@ private final class WallpaperSession { } func pause() { - wantsPlayback = false - updatePlaybackRate() + PerfSignposts.interval(PerfSignposts.pause) { + wantsPlayback = false + updatePlaybackRate() + } } func resume() { - wantsPlayback = true - updatePlaybackRate() + PerfSignposts.interval(PerfSignposts.resume) { + wantsPlayback = true + updatePlaybackRate() + } } private func occlusionDidChange() { @@ -494,7 +500,9 @@ public final class WallpaperController: ObservableObject { } else { // Re-sync rather than just resuming, so any config or space changes // that arrived while paused are picked up immediately. - synchronize(to: spaceMonitor.screenSpaces) + PerfSignposts.interval(PerfSignposts.resume) { + synchronize(to: spaceMonitor.screenSpaces) + } } } @@ -785,19 +793,21 @@ public final class WallpaperController: ObservableObject { private func synchronize(to spaces: [ScreenSpace]) { // NSApp is nil in unit test contexts — can't create windows without a running application guard let app = NSApp else { return } - let appearance = app.effectiveAppearance - sessionManager.synchronize( - to: spaces, - appearance: appearance, - configStore: configStore, - bookmarkManager: bookmarkManager, - isPaused: shouldHaltPlayback, - onPlaybackError: { [weak self] spaceKey, message in - Task { @MainActor in - self?.playbackErrors.send((spaceKey, message)) + PerfSignposts.interval(PerfSignposts.synchronize) { + let appearance = app.effectiveAppearance + sessionManager.synchronize( + to: spaces, + appearance: appearance, + configStore: configStore, + bookmarkManager: bookmarkManager, + isPaused: shouldHaltPlayback, + onPlaybackError: { [weak self] spaceKey, message in + Task { @MainActor in + self?.playbackErrors.send((spaceKey, message)) + } } - } - ) + ) + } } } diff --git a/Sources/Lively/Logic/AppPreferences.swift b/Sources/Lively/Logic/AppPreferences.swift index 07bbaf2..ae915ae 100644 --- a/Sources/Lively/Logic/AppPreferences.swift +++ b/Sources/Lively/Logic/AppPreferences.swift @@ -10,7 +10,7 @@ import AppKit /// assignments) so preference toggles stay lightweight and independent. @MainActor public final class AppPreferences: ObservableObject { - public static let shared = AppPreferences() + public static let shared = AppPreferences(defaults: AppSupportPaths.perfUserDefaults()) /// Hard floor: wallpapers always pause on battery at or below this level. nonisolated public static let forcedBatteryPausePercent: Double = 25 @@ -208,7 +208,7 @@ public final class AppPreferences: ObservableObject { didSet { defaults.set(hasSeenSpacesCoach, forKey: Keys.hasSeenSpacesCoach) } } - public init(defaults: UserDefaults = .standard) { + public init(defaults: UserDefaults = AppSupportPaths.perfUserDefaults()) { self.defaults = defaults func seedDefault(_ key: String, _ value: Any) { diff --git a/Sources/Lively/Logic/AppSupportPaths.swift b/Sources/Lively/Logic/AppSupportPaths.swift new file mode 100644 index 0000000..8ee42a9 --- /dev/null +++ b/Sources/Lively/Logic/AppSupportPaths.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Resolves Application Support. `LIVELY_APPLICATION_SUPPORT` wins so +/// `scripts/perf/measure.sh` can isolate I/O. Setting `HOME` is not enough: +/// `FileManager.SearchPathDomainMask.userDomainMask` still points at the +/// real user Library on current macOS. +public enum AppSupportPaths { + public static func applicationSupportDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> URL { + if let override = environment["LIVELY_APPLICATION_SUPPORT"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory.appendingPathComponent("Application Support", isDirectory: true) + } + + public static func livelyDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> URL { + applicationSupportDirectory(environment: environment, fileManager: fileManager) + .appendingPathComponent("Lively", isDirectory: true) + } + + public static func perfUserDefaults( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> UserDefaults { + let mode = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !mode.isEmpty, mode != "off" else { return .standard } + let suite = "com.lively.app.perf" + if let defaults = UserDefaults(suiteName: suite) { + defaults.removePersistentDomain(forName: suite) + return defaults + } + return .standard + } +} diff --git a/Sources/Lively/Logic/ConfigStore.swift b/Sources/Lively/Logic/ConfigStore.swift index 1d9b3d9..c78531b 100644 --- a/Sources/Lively/Logic/ConfigStore.swift +++ b/Sources/Lively/Logic/ConfigStore.swift @@ -51,19 +51,18 @@ public class ConfigStore: ObservableObject { public init(configFileURL: URL? = nil) { if let provided = configFileURL { self.configFileURL = provided + } else if let envPath = ProcessInfo.processInfo.environment["LIVELY_CONFIG_FILE"], + !envPath.isEmpty { + self.configFileURL = URL(fileURLWithPath: envPath) } else { - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let dir = appSupport.appendingPathComponent("Lively") - do { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } catch { - LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)") - errors.send(.directoryCreationFailed(error)) - } - self.configFileURL = dir.appendingPathComponent("config_v2.json") - } else { - self.configFileURL = URL(fileURLWithPath: "/tmp/lively_config.json") + let dir = AppSupportPaths.livelyDirectory() + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)") + errors.send(.directoryCreationFailed(error)) } + self.configFileURL = dir.appendingPathComponent("config_v2.json") } load() } diff --git a/Sources/Lively/Logic/Diagnostics.swift b/Sources/Lively/Logic/Diagnostics.swift index 3fefb77..6abe50b 100644 --- a/Sources/Lively/Logic/Diagnostics.swift +++ b/Sources/Lively/Logic/Diagnostics.swift @@ -97,9 +97,8 @@ public struct RunMarker: Sendable { } public static var `default`: RunMarker { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - return RunMarker(url: base.appendingPathComponent("Lively", isDirectory: true).appendingPathComponent("running.marker")) + let dir = AppSupportPaths.livelyDirectory() + return RunMarker(url: dir.appendingPathComponent("running.marker")) } /// Call once at launch. Returns true when the previous run left its marker behind. diff --git a/Sources/Lively/Logic/LivelyLogger.swift b/Sources/Lively/Logic/LivelyLogger.swift index 0840a1c..9eac82e 100644 --- a/Sources/Lively/Logic/LivelyLogger.swift +++ b/Sources/Lively/Logic/LivelyLogger.swift @@ -116,4 +116,5 @@ public enum LivelyLogger { public static let videoPreview = LivelyCategoryLogger(category: "VideoPlayerView") public static let spaceMonitor = LivelyCategoryLogger(category: "SpaceMonitor") public static let updater = LivelyCategoryLogger(category: "Updater") + public static let perf = LivelyCategoryLogger(category: "Performance") } diff --git a/Sources/Lively/Logic/PerfHarness.swift b/Sources/Lively/Logic/PerfHarness.swift new file mode 100644 index 0000000..07b93ed --- /dev/null +++ b/Sources/Lively/Logic/PerfHarness.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Isolated launch path for `scripts/perf/measure.sh`. +/// +/// Reads `LIVELY_PERF_MODE` and `LIVELY_PERF_FIXTURE`. Does not change the +/// config schema or spaceKey format. Measure with a throwaway `HOME` so the +/// operator's real Application Support is never touched. +public enum PerfHarness { + public enum Mode: String, Equatable, Sendable { + case off + case idle + case playing + case paused + } + + public struct Request: Equatable, Sendable { + public var mode: Mode + public var fixtureURL: URL? + + public static func parse( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Request { + let raw = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, let mode = Mode(rawValue: raw), mode != .off else { + return Request(mode: .off, fixtureURL: nil) + } + let fixture = environment["LIVELY_PERF_FIXTURE"] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) } + return Request(mode: mode, fixtureURL: fixture) + } + } + + public static func isValidRequest(_ request: Request) -> Bool { + switch request.mode { + case .off, .idle: + return true + case .playing, .paused: + guard let url = request.fixtureURL else { return false } + return isValidLivelyVideoFile(url) + } + } + + /// Assigns the fixture to every currently visible Space via the existing + /// `ConfigStore.assign` API. Paused mode waits one run-loop turn plus a + /// short grace so the player is built before `togglePause`. + @MainActor + public static func apply( + _ request: Request, + spaceMonitor: SpaceMonitor, + configStore: ConfigStore, + wallpaperController: WallpaperController + ) { + guard request.mode != .off else { return } + guard isValidRequest(request) else { + LivelyLogger.perf.error("Perf harness: playing/paused requires a supported LIVELY_PERF_FIXTURE") + return + } + + spaceMonitor.refresh() + + if request.mode == .idle { + LivelyLogger.perf.info("Perf harness: idle (no wallpaper assigned)") + return + } + + guard let url = request.fixtureURL else { return } + + var wallpaper = DynamicWallpaper() + wallpaper.mode = .staticVideo + wallpaper.staticURL = url + wallpaper.isMuted = true + wallpaper.volume = 0 + + let spaces = spaceMonitor.screenSpaces + for space in spaces { + configStore.assign(dynamicWallpaper: wallpaper, toSpaceKey: space.spaceKey) + } + LivelyLogger.perf.info("Perf harness: assigned fixture to \(spaces.count) space(s)") + + if request.mode == .paused { + // Config sink refreshes asynchronously; pause after the player is up + // so the paused footprint includes a resident AVPlayer (hypothesis B). + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if !wallpaperController.isPaused { + wallpaperController.togglePause() + } + } + } + } +} diff --git a/Sources/Lively/Logic/PerfSignposts.swift b/Sources/Lively/Logic/PerfSignposts.swift new file mode 100644 index 0000000..e1cc822 --- /dev/null +++ b/Sources/Lively/Logic/PerfSignposts.swift @@ -0,0 +1,27 @@ +import os + +/// `os_signpost` intervals for Instruments. Cheap when a recording is not +/// attached: begin/end become no-ops at the tracing layer. +/// +/// Names match `docs/PERFORMANCE_MASTER_PLAN.md` P0: launch, synchronize, +/// playerBuild, pause, resume. +public enum PerfSignposts { + public static let subsystem = "com.lively.app" + public static let category = "Performance" + + public static let launch: StaticString = "launch" + public static let synchronize: StaticString = "synchronize" + public static let playerBuild: StaticString = "playerBuild" + public static let pause: StaticString = "pause" + public static let resume: StaticString = "resume" + + public static let signposter = OSSignposter(subsystem: subsystem, category: category) + + /// Runs `body` inside a matching begin/end interval. + @discardableResult + public static func interval(_ name: StaticString, _ body: () throws -> T) rethrows -> T { + let state = signposter.beginInterval(name) + defer { signposter.endInterval(name, state) } + return try body() + } +} diff --git a/Sources/Lively/Logic/WallpaperLibraryManager.swift b/Sources/Lively/Logic/WallpaperLibraryManager.swift index 542ab43..ec7cb94 100644 --- a/Sources/Lively/Logic/WallpaperLibraryManager.swift +++ b/Sources/Lively/Logic/WallpaperLibraryManager.swift @@ -22,6 +22,9 @@ public final class WallpaperLibraryManager: ObservableObject { if let libraryDir { self.libraryDir = libraryDir + } else if ProcessInfo.processInfo.environment["LIVELY_APPLICATION_SUPPORT"] != nil { + self.libraryDir = AppSupportPaths.livelyDirectory() + .appendingPathComponent("Library", isDirectory: true) } else if let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { self.libraryDir = support.appendingPathComponent("Lively/Library", isDirectory: true) } else { diff --git a/Sources/LivelyApp/main.swift b/Sources/LivelyApp/main.swift index 5943c57..25f90b4 100644 --- a/Sources/LivelyApp/main.swift +++ b/Sources/LivelyApp/main.swift @@ -27,17 +27,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private let runMarker = RunMarker.default func applicationDidFinishLaunching(_ notification: Notification) { - if runMarker.begin() { - LogStore.shared.previousRunEndedUncleanly = true - LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)") + PerfSignposts.interval(PerfSignposts.launch) { + if runMarker.begin() { + LogStore.shared.previousRunEndedUncleanly = true + LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)") + } + // Background-only process — no Dock icon + NSApp.setActivationPolicy(.accessory) + + // Warm up the controller (triggers initial wallpaper load for all screens) + _ = wallpaperController + + setupMenuBar() + + let request = PerfHarness.Request.parse() + if request.mode != .off { + PerfHarness.apply( + request, + spaceMonitor: spaceMonitor, + configStore: configStore, + wallpaperController: wallpaperController + ) + } } - // Background-only process — no Dock icon - NSApp.setActivationPolicy(.accessory) - - // Warm up the controller (triggers initial wallpaper load for all screens) - _ = wallpaperController - - setupMenuBar() } func applicationWillTerminate(_ notification: Notification) { @@ -84,7 +96,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // Apply saved appearance (Light / Dark / System) before first paint. AppPreferences.applyAppAppearance(AppPreferences.shared.appearance) - if AppPreferences.shared.checkForUpdates { + let perfMode = PerfHarness.Request.parse().mode + if AppPreferences.shared.checkForUpdates && perfMode == .off { Task { await UpdateChecker.shared.checkIfEnabled() } } } diff --git a/Tests/LivelyTests/AppSupportPathsTests.swift b/Tests/LivelyTests/AppSupportPathsTests.swift new file mode 100644 index 0000000..2053873 --- /dev/null +++ b/Tests/LivelyTests/AppSupportPathsTests.swift @@ -0,0 +1,29 @@ +import Testing +import Foundation +@testable import LivelyCore + +struct AppSupportPathsTests { + @Test func overrideBeatsUserDomain() { + let dir = AppSupportPaths.applicationSupportDirectory( + environment: ["LIVELY_APPLICATION_SUPPORT": "/tmp/lively-perf-support"] + ) + #expect(dir.path == "/tmp/lively-perf-support") + let lively = AppSupportPaths.livelyDirectory( + environment: ["LIVELY_APPLICATION_SUPPORT": "/tmp/lively-perf-support"] + ) + #expect(lively.path == "/tmp/lively-perf-support/Lively") + } + + @Test func emptyOverrideFallsBack() { + let dir = AppSupportPaths.applicationSupportDirectory(environment: ["LIVELY_APPLICATION_SUPPORT": " "]) + #expect(!dir.path.contains("lively-perf-support")) + #expect(dir.path.contains("Application Support")) + } + + @Test func perfDefaultsUseIsolatedSuite() { + let defaults = AppSupportPaths.perfUserDefaults(environment: ["LIVELY_PERF_MODE": "idle"]) + #expect(defaults !== UserDefaults.standard) + let off = AppSupportPaths.perfUserDefaults(environment: [:]) + #expect(off === UserDefaults.standard) + } +} diff --git a/Tests/LivelyTests/PerfHarnessTests.swift b/Tests/LivelyTests/PerfHarnessTests.swift new file mode 100644 index 0000000..97c47fb --- /dev/null +++ b/Tests/LivelyTests/PerfHarnessTests.swift @@ -0,0 +1,43 @@ +import Testing +import Foundation +@testable import LivelyCore + +struct PerfHarnessTests { + @Test func parseMissingEnvIsOff() { + let request = PerfHarness.Request.parse(environment: [:]) + #expect(request.mode == .off) + #expect(request.fixtureURL == nil) + #expect(PerfHarness.isValidRequest(request)) + } + + @Test func parseUnknownModeIsOff() { + let request = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "turbo"]) + #expect(request.mode == .off) + } + + @Test func parseIdle() { + let request = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "idle"]) + #expect(request.mode == .idle) + #expect(PerfHarness.isValidRequest(request)) + } + + @Test func playingRequiresSupportedFixture() { + let missing = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "playing"]) + #expect(missing.mode == .playing) + #expect(!PerfHarness.isValidRequest(missing)) + + let badExt = PerfHarness.Request.parse(environment: [ + "LIVELY_PERF_MODE": "playing", + "LIVELY_PERF_FIXTURE": "/tmp/clip.avi" + ]) + #expect(!PerfHarness.isValidRequest(badExt)) + + let ok = PerfHarness.Request.parse(environment: [ + "LIVELY_PERF_MODE": "PAUSED", + "LIVELY_PERF_FIXTURE": "/tmp/clip.mp4" + ]) + #expect(ok.mode == .paused) + #expect(ok.fixtureURL?.path == "/tmp/clip.mp4") + #expect(PerfHarness.isValidRequest(ok)) + } +} diff --git a/Tests/LivelyTests/PerfSignpostsTests.swift b/Tests/LivelyTests/PerfSignpostsTests.swift new file mode 100644 index 0000000..2ad5527 --- /dev/null +++ b/Tests/LivelyTests/PerfSignpostsTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import LivelyCore + +struct PerfSignpostsTests { + @Test func intervalRunsBodyAndReturnsValue() { + var ran = false + let value = PerfSignposts.interval("test") { + ran = true + return 42 + } + #expect(ran) + #expect(value == 42) + } + + @Test func intervalRethrows() { + enum Probe: Error { case boom } + var caught = false + do { + try PerfSignposts.interval("test") { + throw Probe.boom + } + } catch is Probe { + caught = true + } catch { + Issue.record("unexpected error \(error)") + } + #expect(caught) + } + + @Test func subsystemAndCategoryMatchBundle() { + #expect(PerfSignposts.subsystem == "com.lively.app") + #expect(PerfSignposts.category == "Performance") + } +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 8feae37..8e16074 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -1,17 +1,110 @@ # CPU, GPU and battery budget -Targets (to be measured with Activity Monitor / Instruments on real hardware; they -were not measured in the audit environment): - -- Idle with no wallpaper assigned: no decoding, negligible CPU. -- Playing 1080p HEVC on Apple silicon: hardware decode, low single-digit CPU percent per display. -- Nothing visible, nothing decoded: playback stops when displays or the Mac sleep, the - screen is locked or the screen saver runs, the user session is inactive, or the - wallpaper window is fully occluded (for example by a full-screen app). -- Battery: playback pauses at or below 25% on battery (forced) and at the user threshold - (default 35%) when "Pause on Battery" is on; it resumes on AC. -- Thermal: playback is throttled/paused under serious thermal pressure. -- User controls: Playback Quality (peak bit rate cap), Max Resolution, hardware decoding. - -Known gap: the wallpaper video itself does not yet react to the system Reduce Motion -setting. Tracked as a follow-up (design decision: pause on first frame vs. keep playing). +Policy lives in `docs/PERFORMANCE_MASTER_PLAN.md`. How to reproduce: +`scripts/perf/README.md`. Later phases compare to the P0 baseline below. +Do not invent numbers; a cell is `—` when that sampler did not run. + +## Targets (budgets) + +Measure on Apple silicon, release `.app`, 1080p HEVC 30 fps looping wallpaper +on one display, AC power, idle desktop, popover closed. + +| Metric | Budget | Tool | +|---|---|---| +| Resident memory, wallpaper playing, popover closed | <= 60 MB (stretch 40 MB) | `footprint -p`, Instruments Allocations | +| Resident memory, paused or suspended | <= 25 MB | `footprint` | +| Resident memory, no wallpaper assigned | <= 20 MB | `footprint` | +| App CPU, playing 1080p30 HEVC | <= 1.5% of one core (stretch 1%) | `top -pid`, Activity Monitor | +| App CPU, paused or idle, 10 min average | 0.0% | `top`, `sample` | +| Wake-ups per second, idle | <= 1 (target 0) | `powermetrics --samplers tasks`, Activity Monitor Idle Wake Ups | +| Energy Impact, playing | <= 3 (stretch 2) | Activity Monitor, Instruments Energy Log | +| Energy Impact, paused | ~0 | Activity Monitor | +| Time from pause trigger to decoder stopped | <= 100 ms | os_signpost `pause` | +| Time from resume to first frame | <= 300 ms | os_signpost `resume` / `playerBuild` | +| Cold-launch to menu-bar ready | <= 250 ms | os_signpost `launch` | +| Release binary size (unstripped `.app` executable) | track; stretch <= 2 MB | `ls -l` | + +If a budget is physically impossible, record the measured floor and why. + +## Safety pauses (do not remove) + +Playback stops when displays or the Mac sleep, the screen is locked, the +screen saver runs, the user session is inactive, the wallpaper window is +fully occluded, battery is at or below 25% (forced) or the user threshold +(default 35% when "Pause on Battery" is on), or thermal state is serious +or critical. + +User controls: Playback Quality (peak bit rate cap), Max Resolution, +hardware decoding. + +Known gap: the wallpaper video itself does not yet react to Reduce Motion +(P4 / hypothesis K). + +## P0 baseline + +Recorded 2026-10-13 on this machine. Host: Apple M1, macOS 27.0.1. +Release `.app` from `./package.sh` (Command Line Tools) after the +Application Support isolation fix. Popover closed (`startMinimized` +default). Fixture: `perf/fixtures/1080p30-hevc-silent.mp4` (2 s loop). +Sample window 30 s after a 5–8 s settle. Git parent at measure time: +`0b93d3b`; the packaged binary also included Application Support +isolation (`LIVELY_APPLICATION_SUPPORT`, `LIVELY_CONFIG_FILE`, perf +UserDefaults suite). JSON: +`perf/results/20261013T141319Z-0b93d3b-idle.json` and the matching +playing / paused files (gitignored). + +Isolation: setting `HOME` does **not** redirect +`FileManager.SearchPathDomainMask.userDomainMask`. The harness must set +`LIVELY_APPLICATION_SUPPORT` and `LIVELY_CONFIG_FILE`. Proof on this +run: user `config_v2.json` checksum `2558921003 1531` was unchanged. + +Discard earlier tables from this day. The 20 s `85e332a` run and the +60 s `0b93d3b` run used `HOME` only; the 60 s idle sample was actually +playing a user wallpaper (~3% CPU, AVPlayer in `sample(1)`). + +`footprint` values are `phys_footprint` (mebibytes = bytes / 1024²). +`top` %CPU is macOS one-core percent (can exceed 100% on many cores). + +| Metric | Idle (no wallpaper) | Playing 1080p30 HEVC | Paused | Budget | +|---|---|---|---|---| +| `footprint` resident | 14 MB (peak 15) | 21 MB (peak 22) | 20 MB (peak 21) | 20 / 60 / 25 MB | +| `top` CPU % | 0.0 (all 6 samples) | 4.783 avg; last five 5.5, 5.7, 6.1, 5.5, 5.9 | 0.133 avg; samples 0.0, 0.3, 0.0, 0.5, 0.0, 0.0 | 0.0 / 1.5 / 0.0 | +| Threads (`top` #TH) | 9 → 3 | 25–27 | 8–10 | — | +| Idle wake-ups / s | — | — | — | <= 1 idle | +| Energy Impact | — | — | — | 0 / 3 / 0 | +| Release executable | 4,366,496 bytes (4.16 MiB) | same | same | track; stretch 2 MB | + +`sample(1)` on the same PIDs: idle is CFRunLoop waiting (CoreMedia +linked, not on-CPU); playing is AVPlayer / CoreMedia decode; paused +still has `figThreadMain` waiting on Fig semaphores. + +RAM is inside the idle/playing/paused budgets. Playing CPU is **above** +the 1.5% budget once decode is up (~5.7% of one core on this 30 s +window). Paused RAM stays ~6 MB above idle with CoreMedia threads still +resident — a **hint** for hypothesis B, not a P1 confirmation. Paused +CPU is not a clean 0.0 on this window. + +### Not measured here (need the operator) + +`sudo -n` was not available, so `powermetrics` and Energy Impact are +blank on purpose. + +```bash +# Idle wake-ups and process energy, 30 s, while Lively is in the mode you care about: +sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 30 --process-name Lively + +# Energy Log (Instruments) +xctrace record --template 'Energy Log' --time-limit 30s \ + --output /tmp/lively-energy.trace \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively + +# Signpost intervals (launch / synchronize / playerBuild / pause / resume) +xctrace record --template 'Time Profiler' --time-limit 30s \ + --output /tmp/lively-time.trace \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively +``` + +Or Activity Monitor: select Lively → Energy tab (Energy Impact, Idle +Wake Ups) while the harness is running (`LIVELY_PERF_SKIP_BUILD=1 +scripts/perf/measure.sh playing 60`). diff --git a/docs/PERFORMANCE_MASTER_PLAN.md b/docs/PERFORMANCE_MASTER_PLAN.md new file mode 100644 index 0000000..f732f30 --- /dev/null +++ b/docs/PERFORMANCE_MASTER_PLAN.md @@ -0,0 +1,167 @@ +# Lively Performance Master Plan + +Goal: the lightest possible live-wallpaper app on macOS. Minimal RAM, near-zero idle CPU, minimal battery impact. +Audience: an autonomous coding agent. Execute phases in order. Every phase ends with a measurement and a commit. + +## 0. Decision: do NOT rewrite in Rust + +Lively is already native Swift/AppKit: ~7.8k LOC, zero third-party dependencies, SPM, macOS 14+. +The real cost centres are not the language: + +1. Video decode, which is done by the Apple Silicon media engine through AVFoundation. Rust cannot beat it. +2. WindowServer compositing of the wallpaper window, which is OS-owned. +3. SwiftUI/Combine/popover memory that stays resident while the app sits in the menu bar. +4. Wake-ups (timers, polling, notification fan-out) that keep the CPU out of deep idle. + +A Rust rewrite would add FFI to AVFoundation/AppKit, cost months, and reduce nothing in 1 and 2. +Rust or C is only justified for a hot path that a profile proves is ours. None is expected. +Strategy: measure, remove wake-ups, free memory when invisible, shrink the video workload, shrink the binary. + +## 1. Current state (audited 2026-10-13) + +- Local `master` is in sync with `origin/master` (harshabala/lively) at `2d83093` "Production readiness pass (#7)". +- Open PR: Dependabot `actions/checkout 4 -> 7`. Review CI, then merge. Not performance-related. +- Untracked Finder/iCloud duplicates ("... 2.md", "... 2.png", including `.impeccable/critique/...2.md`). Delete or gitignore them. Check `diff` against the originals first. +- Stack: `LivelyCore` library (Sources/Lively), `LivelyApp` executable (main.swift, AppDelegate, NSStatusItem, NSPopover with NSHostingController(SettingsContainerView)). +- Big files: `ScreenCardView.swift` 942 lines, `WallpaperController.swift` 850, `PreferencesView.swift` 563, `ConfigStore.swift` 425, `LibraryView.swift` 422. +- `docs/PERFORMANCE.md` says its targets were never measured on hardware. This plan fixes that first. + +### What is already good (keep) +- Pauses on display sleep, system sleep, lock, screen saver, fast-user-switch (`SystemSuspension`). +- Pauses when the wallpaper window is fully occluded (`didChangeOcclusionState`). +- Battery floor 25%, user threshold, thermal throttling, quality and resolution caps, `preferredPeakBitRate`. +- ConfigStore persistence is debounced (300 ms). +- `automaticallyWaitsToMinimizeStalling = false`, `preventsDisplaySleepDuringVideoPlayback = false`. + +### Inefficiencies found in code (verify each with a profile before and after) +| # | Where | Issue | Fix (phase) | +|---|-------|-------|-------------| +| A | `WallpaperController.setupBatteryMonitor` | 20 s repeating `Timer` plus a `Task` hop forever, even while idle or paused. Constant wake-ups. | Event-driven `IOPSNotificationCreateRunLoopSource`; no timer (P2) | +| B | `WallpaperSession.pause()` | Pausing only calls `player.pause()`. The AVPlayer, item, decoded frames and buffers stay resident for hours while suspended or battery-paused. | Tear down the player after a short grace period; rebuild on resume from a poster frame (P3) | +| C | `NSHostingController` in main.swift | The SwiftUI settings tree (LibraryView, ScreenCardView, thumbnails, Combine subscriptions) is built at launch and kept alive for the app's life. | Create the popover content lazily on first open; destroy on close (P3) | +| D | `LogStore` | Up to 1000 `LogEntry` objects held in a `@Published` array on the main actor, mirroring `os.Logger`. Every log line fires objectWillChange. | Drop the in-memory mirror; read from `OSLogStore` on demand, or use a ring buffer that is not `@Published` while the log view is closed (P3) | +| E | `configStore.$configs` sink | Every config change triggers `retryFailedPlayback()` and `spaceMonitor.refresh()` (rebuilds all `ScreenSpace` values, re-queries `desktopImageURL`). | Diff first; refresh only when the affected spaceKey changed (P2) | +| F | `SpaceMonitor.handleSpaceChange` | Polls up to 10 x 50 ms on each Space switch. | Fine functionally. Cap work, skip when no display has a wallpaper assigned (P2) | +| G | Loop via `seek(to: .zero)` | Seek on item end causes a decoder flush, a stutter and a CPU spike every loop. | `AVPlayerLooper`/`AVQueuePlayer`, or an `AVAssetReader` + `AVSampleBufferDisplayLayer` pipeline if profiling shows it wins (P4) | +| H | Muted wallpapers | A muted `AVPlayer` still decodes the audio track. | Disable audio tracks on the item (`AVPlayerItemTrack.isEnabled = false`) or strip audio at import (P4) | +| I | Same file on 2+ displays | One `AVPlayer` and one decode per display. | Share a single `AVPlayer` across several `AVPlayerLayer`s for identical URL and settings (P4) | +| J | Source video can be 4K/60 fps | Only a bit-rate and max-height hint is applied. The decoder still works on the full stream. | Import-time transcode to a "Lively-optimised" HEVC at display-native size, 30 fps cap (P4). Biggest battery lever. | +| K | Reduce Motion gap noted in PERFORMANCE.md | Wallpaper ignores the accessibility setting. | Show poster frame and stop decoding when Reduce Motion is on (P4) | +| L | `AppMetrics` | A `DateFormatter` allocated for a once-a-day string. | Use `Date.FormatStyle` or compare `Calendar.startOfDay`. Trivial (P5) | +| M | `UpdateChecker` | Network call on every launch. | Throttle to once per 24 h, `URLSession` ephemeral, no retained session (P5) | +| N | Binary | Default release flags. | `-Osize`, WMO, dead-strip, strip symbols, no unused frameworks (P6) | + +## 2. Budgets (the definition of done) + +Measure on Apple silicon, release build, 1080p HEVC 30 fps looping wallpaper on one external or built-in display, AC power, idle desktop. + +| Metric | Budget | Tool | +|---|---|---| +| Resident memory, wallpaper playing, popover closed | <= 60 MB (stretch 40 MB) | `footprint -p`, Instruments Allocations | +| Resident memory, paused or suspended | <= 25 MB | `footprint` | +| Resident memory, no wallpaper assigned | <= 20 MB | `footprint` | +| App CPU, playing 1080p30 HEVC | <= 1.5% of one core (stretch 1%) | `top -pid`, Activity Monitor | +| App CPU, paused or idle, 10 min average | 0.0% | `top`, `sample` | +| Wake-ups per second, idle | <= 1 (target 0) | `powermetrics --samplers tasks`, Activity Monitor "Idle Wake Ups" | +| Energy Impact, playing | <= 3 (stretch 2) | Activity Monitor, Instruments Energy Log | +| Energy Impact, paused | ~0 | Activity Monitor | +| Time from pause trigger to decoder stopped | <= 100 ms | os_signpost | +| Time from resume to first frame | <= 300 ms | os_signpost | +| Cold-launch to menu-bar ready | <= 250 ms | os_signpost | +| Release binary size (unstripped .app executable) | track and do not regress; stretch <= 2 MB | `ls -l`, `bloaty` | + +Budgets are targets. If a budget is physically impossible, record the measured floor and the reason in `docs/PERFORMANCE.md`. Do not fudge numbers. + +## 3. Phases + +### P0. Hygiene and baseline (do first, about half a day) +1. Delete or ignore the "... 2" duplicates after confirming they are byte-identical or stale. +2. Merge the Dependabot PR if CI is green. +3. Create `scripts/perf/` with: + - `measure.sh`: builds release, launches with a fixture wallpaper, samples `top` for N minutes, runs `footprint`, `powermetrics` (needs sudo, document it), writes `perf/results/-.json`. + - A fixture set in `perf/fixtures/` (small generated clips: 1080p30 HEVC, 4K60 HEVC, 1080p30 H.264, with and without audio). Generate with `ffmpeg` or `avconvert`; do not commit large binaries (use Git LFS or generate on demand). +4. Add `os_signpost` intervals (`OSSignposter`) around: launch, synchronize, player build, pause, resume. Keep them; they are free when not profiled. +5. Record the BASELINE in `docs/PERFORMANCE.md` as a table. Every later phase compares to it. No optimisation without a before and after number. + +Exit: baseline table committed. CI still green. + +### P1. Instruments pass (read-only, about half a day) +Run Time Profiler, Allocations, Energy Log, System Trace (wake-ups) for: idle, playing, paused, popover open and closed, Space switching, display hot-plug. Write findings to `docs/perf-findings.md`. Confirm or refute rows A to N above and add anything new. Re-rank by measured impact. Do not skip. The table above is a hypothesis from reading code. + +### P2. Kill wake-ups (target: idle wake-ups to ~0) +- A: replace the 20 s timer with `IOPSNotificationCreateRunLoopSource` (fires only on power-source change). Also observe `NSProcessInfoPowerStateDidChange` (already done) and `ProcessInfo.thermalStateDidChangeNotification` (already done). Initial read once at launch. Keep a pure function `batteryPauseDecision` and its tests. +- E: in the `configStore.$configs` sink, compute which spaceKeys changed; call `spaceMonitor.refresh()` only if a current space's config changed. Add `removeDuplicates`. +- F: skip the poll loop when no screen has an assigned wallpaper. +- Audit every `Timer`, `Task.sleep`, `asyncAfter`, `Combine.publish(every:)`, and `NotificationCenter` observer. Each must have a documented reason. Remove observers that are not needed while paused (for example the occlusion observer). +- Coalesce rapid-fire notifications (display reconfiguration storms) with a single trailing 100 ms debounce. +- Add a test that no repeating timer exists after `WallpaperController.init` (inject a scheduler or assert `powerSourceTimer == nil`). + +Exit: `powermetrics` shows idle wake-ups under budget; paused CPU 0.0%. + +### P3. Memory: free everything that is not visible +- B: "Cold pause". When suspension or pause lasts longer than a grace period (suggest 10 s for lock, sleep and screen saver; 0 s for battery and thermal pauses), call `tearDownPlayer()` and keep only the window with a static poster frame (`CALayer.contents` as a `CGImage` at display pixel size; store the JPEG or HEIC poster in Caches). On resume, rebuild the player. Must not flash black: keep the poster visible until the first decoded frame arrives (`AVPlayerLayer.isReadyForDisplay`). +- Optional: when occluded for more than N seconds, apply the same cold pause. +- C: make the popover content lazy. `NSPopover.contentViewController` is set on first show, and set to `nil` in `popoverDidClose` (after a short delay) so SwiftUI state, thumbnails and Combine sinks are released. Verify with Allocations that memory drops after closing. +- D: LogStore. Remove the `@Published` 1000-entry array. Options in order of preference: (1) read `OSLogStore(scope: .currentProcessIdentifier)` when the log view opens; (2) a plain non-observable ring buffer of 200 entries, published only while the log view is visible. +- Thumbnails: `VideoThumbnailView` and `LibraryView` must use `AVAssetImageGenerator` with `maximumSize` set to the displayed pixel size, cache to disk in Caches, keep an `NSCache` with a small count/cost limit, and evict on `popoverDidClose`. +- Make `ScreenCardView` (942 lines) cheaper: split into small views with equatable inputs, avoid `@EnvironmentObject` fan-out so one `@Published` change does not re-render every card. This is also a maintainability win. +- Replace `@Published` + `ObservableObject` on hot controllers with `@Observable` (macOS 14 supports it) to cut invalidation. Do this only where P1 shows view-update cost. + +Exit: footprint paused <= 25 MB, popover-closed <= budget, no growth after 50 open/close cycles of the popover (leak check with the memory-leak workflow). + +### P4. Decode workload: the biggest battery lever +- J: Import pipeline "Optimise for Lively": on import (and optionally on first play), transcode to a cached HEVC file: + - Resolution = display native pixel size, never larger (use the max across assigned displays, or one rung per display). + - 30 fps cap (24 fps offered as "Eco"), constant frame rate. + - Strip audio if the wallpaper is muted (the default). + - Bitrate target about 4 to 8 Mbps for 1080p; GOP and keyframe interval short enough for cheap looping. + - Use `AVAssetExportSession` with a preset or `AVAssetWriter` for control. Run it as a background `Process`-free task with `QualityOfService.utility`, cancellable, with progress. Keep the original untouched; cache under `~/Library/Caches` or the app container. + - Skip transcoding if the source already meets the spec (reuse `VideoValidator`). +- G: seamless loop without seek-flush: `AVPlayerLooper` with `AVQueuePlayer` as first step. Measure CPU spikes at loop boundaries. Only if still visible, evaluate `AVAssetReader` + `AVSampleBufferDisplayLayer` (more control, more code; adopt only with a measured win). +- H: if the item is muted, disable the audio `AVPlayerItemTrack`s so no audio decode or render graph exists. +- I: share one `AVPlayer` across displays that show the same URL and settings; lay out multiple `AVPlayerLayer`s on it. +- K: respect `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion`: show the poster frame, no decode. Add an observer for `accessibilityDisplayOptionsDidChangeNotification`. Update docs and tests. +- Low Power Mode: when `ProcessInfo.isLowPowerModeEnabled`, default to 24 fps or poster-only (user-configurable, default on). +- Smart "static after idle": optional setting to freeze on the poster frame after N minutes without user input on battery. Use `CGEventSource.secondsSinceLastEventType`, polled only when a decision is pending (no always-on timer; use a single one-shot timer re-armed on state changes). +- Never use software decode paths. Remove the "hardware decoding off" option unless there is a hard reason to keep it; if kept, document the energy cost. + +Exit: playing CPU and Energy Impact meet budget on the 1080p30 fixture; the 4K60 fixture after transcode behaves like the 1080p30 one. + +### P5. Small leaks of cycles +- L: replace `DateFormatter` in `AppMetrics`. +- M: throttle `UpdateChecker` to once per 24 h, using an ephemeral session, off the main actor, and never at launch. Defer 30 s after launch at `.background` QoS. +- Use `OSAllocatedUnfairLock` or actors consistently; remove stray `DispatchQueue.main.asyncAfter` where a state change is the correct trigger (`main.swift` initial popover open). +- Launch path: construct `UpdateChecker`, `LogStore`, `AppMetrics`, `Diagnostics` lazily. Do not touch the UI layer until the user clicks the status item (unless "Start Minimized" is off). +- Set QoS everywhere: UI `.userInteractive`, persistence `.utility`, transcoding `.utility`, update check `.background`. +- Use `NSStatusItem` image as a template `NSImage` created once. +- Ensure `Info.plist` has `LSUIElement` = YES (already accessory policy), no unused entitlements. + +### P6. Build and binary size +- Release settings: `-Osize` (benchmark against `-O`; keep the faster one if the size gap is under 5%), `-whole-module-optimization`, `-Xlinker -dead_strip`, strip symbols, `-Xfrontend -enable-lto` or `-lto=llvm-thin` if supported by the toolchain. Evaluate Swift 6 embedded-style flags only if they build; do not chase them. +- Dependency audit: confirm no SwiftUI or Combine usage outside the settings UI. Consider replacing Combine in `LivelyCore` logic with plain `NotificationCenter` observers and closures; this shrinks the runtime footprint and removes allocation churn. +- Consider an AppKit-only settings window for the hottest screens only if P3 shows SwiftUI is still the dominant resident cost after lazy-load. Otherwise leave SwiftUI. +- Prelink and launch time: check `DYLD_PRINT_STATISTICS`-style output or `xctrace` App Launch template; remove any `+load` or heavy static initialisers. +- Keep `scripts/make-dmg.sh` and notarisation flow intact. + +### P7. Regression guard (so it stays fast) +- `scripts/perf/measure.sh` runs in a manually triggered GitHub Action on a macOS runner (hardware numbers on hosted runners are noisy; use as trend, not gate). Local runs on real Apple silicon are the gate. +- Add unit tests for: cold-pause state machine (suspension reasons x grace timers), no-repeating-timers invariant, battery decision table, Reduce Motion path, lazy popover lifecycle (no hosting controller retained when closed). +- Add `docs/PERFORMANCE.md` as the single source of truth: budgets, latest measured numbers, how to reproduce. +- Add a PR checklist item: "Does this add a timer, observer, or retained view? Justify." + +## 4. Rules for the executing agent +- One phase per branch and PR (`perf/p2-wakeups`, etc.). Small commits. All existing tests must pass (`swift test`). Add tests for any new logic. +- Every PR description must include BEFORE and AFTER numbers from `measure.sh`. No numbers means no merge. +- Do not change user-visible behaviour or config formats (security-scoped bookmarks, spaceKey format, `ConfigStore` schema). Migrations need tests. +- Do not add third-party dependencies. +- Do not remove the safety pauses (sleep, lock, thermal, battery floor). +- No black flashes: a wallpaper window may only be shown with content (poster or video frame). +- If a hypothesis in section 1 is disproved by profiling, say so in `docs/perf-findings.md` and skip the item. +- Stop and ask the owner before: changing minimum macOS version, removing a user-facing setting, or any rewrite of a module larger than ~300 lines. + +## 5. Expected payoff (ranked, to be confirmed by P1) +1. P4 transcode to native-size 30 fps HEVC plus audio strip: largest CPU, GPU and Energy gain while playing. +2. P3 cold pause and lazy popover: largest RAM gain while idle or paused. +3. P2 removal of the 20 s timer and other wake-ups: removes the constant idle drain. +4. P4 shared player across displays and seamless loop: removes loop spikes and halves multi-display decode. +5. P6 build flags: smaller binary and faster launch, modest runtime effect. diff --git a/docs/perf-findings.md b/docs/perf-findings.md new file mode 100644 index 0000000..06966d7 --- /dev/null +++ b/docs/perf-findings.md @@ -0,0 +1,32 @@ +# Perf findings + +Hypothesis table (A–N) lives in `docs/PERFORMANCE_MASTER_PLAN.md` §1. P1 +must confirm or refute each item with Instruments. Until then, treat the +table as unread code, not measured fact. + +## P0 (this branch) + +- Finder/iCloud `* 2.*` duplicates removed (byte-identical except + `Lively-task-list-plan 2.md`, which was an older copy of the tracked file). +- Dependabot `actions/checkout` 4 → 7 merged on `master` as #8 (CI green). +- Signposts: `launch`, `synchronize`, `playerBuild`, `pause`, `resume` + (`com.lively.app` / `Performance`). +- `scripts/perf/measure.sh` writes JSON under `perf/results/`. Null fields + mean the sampler did not run. Do not fill them in by hand. +- Isolation: `HOME` does not move `userDomainMask`. Harness sets + `LIVELY_APPLICATION_SUPPORT`, `LIVELY_CONFIG_FILE`, and a throwaway + `com.lively.app.perf` UserDefaults suite. `sample(1)` is captured when + present (CLT; no Xcode). +- Canonical baseline is the isolated 30 s run in `docs/PERFORMANCE.md`. + Playing CPU (~5.7% once decode is up) misses the 1.5% budget. Paused + footprint 20 MB vs idle 14 MB, with `figThreadMain` still waiting — + treat as a **hint** for hypothesis B, not a P1 confirmation. Wake-ups + and Energy Impact were not sampled (powermetrics needs sudo). +- Earlier same-day tables that used `HOME` only are invalid. One leaked + run wrote the fixture into the real `config_v2.json`; isolation is now + a P0 correctness requirement. + +## P1 (not started) + +See the P1 plan in the P0 pull request. Do not skip. Do not start P2 until +this file records a ranked, measured list. diff --git a/perf/fixtures/.gitkeep b/perf/fixtures/.gitkeep new file mode 100644 index 0000000..ba8ebb7 --- /dev/null +++ b/perf/fixtures/.gitkeep @@ -0,0 +1 @@ +# Generated by scripts/perf/generate-fixtures.sh. Clips are gitignored. diff --git a/scripts/check_hygiene.sh b/scripts/check_hygiene.sh index 814efff..33ce1da 100755 --- a/scripts/check_hygiene.sh +++ b/scripts/check_hygiene.sh @@ -35,7 +35,10 @@ fi # 4. Syntax checks. plutil -lint Info.plist entitlements.plist >/dev/null || note "plist lint failed" -for f in package.sh test.sh scripts/*.sh; do bash -n "$f" || note "bash syntax: $f"; done +for f in package.sh test.sh scripts/*.sh scripts/perf/*.sh; do + [[ -f "$f" ]] || continue + bash -n "$f" || note "bash syntax: $f" +done # 5. Version sanity: Info.plist is the single source for package.sh. v="$(plutil -extract CFBundleShortVersionString raw Info.plist)" diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 0000000..b051e1a --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,72 @@ +# Performance measurement + +P0 harness for `docs/PERFORMANCE_MASTER_PLAN.md`. Numbers come from a real +Apple silicon Mac. Do not paste invented values into `docs/PERFORMANCE.md`. + +## Fixtures + +```bash +scripts/perf/generate-fixtures.sh +``` + +Writes short clips under `perf/fixtures/` (gitignored): + +| File | Purpose | +|------|---------| +| `1080p30-hevc-silent.mp4` | Default playing/paused budget clip | +| `1080p30-hevc-audio.mp4` | Audio-track decode hypothesis (H) | +| `1080p30-h264-silent.mp4` | Software-vs-hardware comparison | +| `4k60-hevc-silent.mp4` | Transcode-to-native hypothesis (J) | + +## Measure + +Quit any running Lively first. The script refuses to start if `Lively` is +already in the process list, so it never kills your daily instance. + +```bash +scripts/perf/measure.sh idle 30 +scripts/perf/measure.sh playing 30 +scripts/perf/measure.sh paused 30 +``` + +Each run packages a release `.app` (skip with `LIVELY_PERF_SKIP_BUILD=1`), +launches the bundle executable (not via `open`) with: + +- `HOME` — temp dir +- `LIVELY_APPLICATION_SUPPORT` — `$HOME/Library/Application Support` +- `LIVELY_CONFIG_FILE` — that tree’s `Lively/config_v2.json` +- `LIVELY_PERF_MODE` / `LIVELY_PERF_FIXTURE` + +`HOME` alone is not enough: `FileManager.userDomainMask` still points at +the real user Library on current macOS. Without the `LIVELY_*` overrides +the harness writes the live config. + +Samples `top`, `footprint`, and `sample(1)` (CLT). Writes +`perf/results/--.json`. Playing and paused assign the +1080p30 HEVC fixture to every visible Space; they cover the desktop until +the script stops the process. + +### powermetrics (sudo) + +```bash +sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 30 --process-name Lively +``` + +If `sudo -n` is not allowed, `measure.sh` records `powermetrics_error` and +leaves `idle_wakeups_per_s` and energy null. + +### Energy Impact + +Activity Monitor → Energy tab, or Instruments Energy Log: + +```bash +xctrace record --template 'Energy Log' --time-limit 30s \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively +``` + +### Signposts + +Release builds emit `os_signpost` intervals (`com.lively.app` / `Performance`): +`launch`, `synchronize`, `playerBuild`, `pause`, `resume`. Time Profiler or +the `os_signpost` Instruments template can attach to them. diff --git a/scripts/perf/generate-fixtures.sh b/scripts/perf/generate-fixtures.sh new file mode 100755 index 0000000..4b5dc74 --- /dev/null +++ b/scripts/perf/generate-fixtures.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Generate small looping clips for scripts/perf/measure.sh. +# Requires ffmpeg. Does not commit the binaries. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="${LIVELY_PERF_FIXTURES:-$ROOT/perf/fixtures}" +mkdir -p "$OUT" + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "generate-fixtures: ffmpeg not found. Install it (e.g. brew install ffmpeg)." >&2 + exit 1 +fi + +make_clip() { + local name="$1" + shift + local dest="$OUT/$name" + if [[ -f "$dest" ]]; then + echo "exists: $dest" + return + fi + echo "generating: $dest" + ffmpeg -hide_banner -loglevel error -y "$@" "$dest" +} + +# 2-second patterns, hardware-friendly tags (hvc1 / avc1). +make_clip "1080p30-hevc-silent.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -an -g 30 + +make_clip "1080p30-hevc-audio.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" \ + -f lavfi -i "sine=frequency=440:sample_rate=48000" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -c:a aac -ac 2 -shortest -g 30 + +make_clip "1080p30-h264-silent.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" -t 2 \ + -c:v libx264 -pix_fmt yuv420p -tag:v avc1 -an -g 30 + +make_clip "4k60-hevc-silent.mp4" \ + -f lavfi -i "testsrc2=size=3840x2160:rate=60" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -an -g 60 + +echo "fixtures in $OUT" +ls -lh "$OUT"/*.mp4 diff --git a/scripts/perf/measure.sh b/scripts/perf/measure.sh new file mode 100755 index 0000000..0abfb02 --- /dev/null +++ b/scripts/perf/measure.sh @@ -0,0 +1,239 @@ +#!/bin/bash +# Measure Lively resource use. Writes perf/results/--.json +# +# Usage: +# scripts/perf/measure.sh [idle|playing|paused] [duration_seconds] +# +# Environment: +# LIVELY_PERF_SKIP_BUILD=1 reuse an existing packaged app +# LIVELY_OUTPUT_DIR package.sh output (default /private/tmp/LivelyOutput) +# LIVELY_PERF_FIXTURE override clip for playing/paused +# LIVELY_PERF_HOME isolated HOME (default a temp dir) +# +# Does not touch ~/Library/Application Support/Lively. HOME is not enough: +# userDomainMask still points at the real Library. The launch also sets +# LIVELY_APPLICATION_SUPPORT and LIVELY_CONFIG_FILE. +# +# powermetrics needs sudo. If sudo is not available the JSON records +# powermetrics_error and the other samplers still run. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +MODE="${1:-idle}" +DURATION="${2:-30}" +case "$MODE" in + idle|playing|paused) ;; + *) + echo "usage: $0 [idle|playing|paused] [duration_seconds]" >&2 + exit 2 + ;; +esac +if ! [[ "$DURATION" =~ ^[0-9]+$ ]] || [[ "$DURATION" -lt 5 ]]; then + echo "duration must be an integer >= 5" >&2 + exit 2 +fi + +COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +RESULTS="$ROOT/perf/results" +mkdir -p "$RESULTS" +RAW_DIR="$RESULTS/${STAMP}-${COMMIT}-${MODE}" +mkdir -p "$RAW_DIR" + +if pgrep -x Lively >/dev/null 2>&1; then + echo "measure: a Lively process is already running. Quit it first so this" >&2 + echo "script does not attach to (or kill) your daily instance." >&2 + exit 1 +fi + +if [[ "${LIVELY_PERF_SKIP_BUILD:-}" != "1" ]]; then + echo "measure: packaging release app" + ./package.sh +fi + +APP="${LIVELY_OUTPUT_DIR:-/private/tmp/LivelyOutput}/Lively.app" +BIN="$APP/Contents/MacOS/Lively" +if [[ ! -x "$BIN" ]]; then + echo "measure: missing $BIN — run ./package.sh" >&2 + exit 1 +fi + +FIXTURE="${LIVELY_PERF_FIXTURE:-}" +if [[ "$MODE" == "playing" || "$MODE" == "paused" ]]; then + if [[ -z "$FIXTURE" ]]; then + "$ROOT/scripts/perf/generate-fixtures.sh" + FIXTURE="$ROOT/perf/fixtures/1080p30-hevc-silent.mp4" + fi + if [[ ! -f "$FIXTURE" ]]; then + echo "measure: fixture not found: $FIXTURE" >&2 + exit 1 + fi +fi + +PERF_HOME="${LIVELY_PERF_HOME:-$(mktemp -d /tmp/lively-perf-home.XXXXXX)}" +SUPPORT="${PERF_HOME}/Library/Application Support" +mkdir -p "$SUPPORT/Lively" + +echo "measure: mode=$MODE duration=${DURATION}s home=$PERF_HOME" +echo "measure: launching $BIN" + +# Launch the bundle executable so env vars inherit. +# `open` goes through LaunchServices and drops those variables. +# HOME is not enough: userDomainMask still points at the real Library. +export HOME="$PERF_HOME" +export LIVELY_APPLICATION_SUPPORT="$SUPPORT" +export LIVELY_CONFIG_FILE="$SUPPORT/Lively/config_v2.json" +export LIVELY_PERF_MODE="$MODE" +if [[ -n "$FIXTURE" ]]; then + export LIVELY_PERF_FIXTURE="$FIXTURE" +fi + +"$BIN" & +PID=$! +sleep 0.4 +if ! kill -0 "$PID" 2>/dev/null; then + echo "measure: Lively did not stay running (pid $PID)" >&2 + exit 1 +fi +echo "measure: pid=$PID" + +# Settle: player build / pause grace (1.5s) plus a couple of seconds of decode. +SETTLE=5 +if [[ "$MODE" == "paused" ]]; then + SETTLE=8 +fi +sleep "$SETTLE" + +TOP_SAMPLES=$(( DURATION / 5 )) +if [[ "$TOP_SAMPLES" -lt 3 ]]; then + TOP_SAMPLES=3 +fi + +echo "measure: sampling top (${TOP_SAMPLES} x 5s)" +top -l "$TOP_SAMPLES" -s 5 -pid "$PID" -stats pid,cpu,mem,rsize,threads \ + > "$RAW_DIR/top.txt" 2>/dev/null || true + +# sample(1) works with Command Line Tools. xctrace/Instruments need full Xcode. +SAMPLE_SECS=10 +if [[ "$DURATION" -lt 15 ]]; then + SAMPLE_SECS=5 +fi +if command -v sample >/dev/null 2>&1; then + echo "measure: sample ${SAMPLE_SECS}s of pid $PID" + sample "$PID" "$SAMPLE_SECS" 1 -mayDie -file "$RAW_DIR/sample.txt" \ + >"$RAW_DIR/sample.err" 2>&1 || true +fi + +echo "measure: footprint" +if footprint -p "$PID" > "$RAW_DIR/footprint.txt" 2>"$RAW_DIR/footprint.err"; then + : +else + echo "measure: footprint failed (see $RAW_DIR/footprint.err)" +fi + +POWERMETRICS_ERROR="" +if command -v powermetrics >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + echo "measure: powermetrics (passwordless sudo)" + sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 10 \ + --process-name Lively \ + > "$RAW_DIR/powermetrics.txt" 2>"$RAW_DIR/powermetrics.err" || \ + POWERMETRICS_ERROR="powermetrics failed; see powermetrics.err" + else + POWERMETRICS_ERROR="sudo required: sudo powermetrics --samplers tasks --show-process-energy --show-process-wait-times -i 1000 -n 30 --process-name Lively" + echo "measure: skipping powermetrics ($POWERMETRICS_ERROR)" + fi +else + POWERMETRICS_ERROR="powermetrics not found" +fi + +BINARY_BYTES="$(stat -f%z "$BIN" 2>/dev/null || wc -c < "$BIN" | tr -d ' ')" +CHIP="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)" +OS_VER="$(sw_vers -productVersion 2>/dev/null || echo unknown)" + +JSON="$RESULTS/${STAMP}-${COMMIT}-${MODE}.json" +MEASURE_STAMP="$STAMP" MEASURE_COMMIT="$COMMIT" MEASURE_MODE="$MODE" \ +MEASURE_DURATION="$DURATION" MEASURE_PID="$PID" MEASURE_CHIP="$CHIP" \ +MEASURE_OS="$OS_VER" MEASURE_BIN="$BINARY_BYTES" \ +MEASURE_POWER_ERR="$POWERMETRICS_ERROR" MEASURE_RAW="$RAW_DIR" MEASURE_JSON="$JSON" \ +python3 <<'PY' +import json, os, re +from pathlib import Path + +pid = os.environ["MEASURE_PID"] +raw_dir = Path(os.environ["MEASURE_RAW"]) + +def cpu_avg(text: str): + vals = [] + for line in text.splitlines(): + parts = line.split() + if parts and parts[0] == pid and len(parts) >= 2: + try: + vals.append(float(parts[1])) + except ValueError: + pass + if not vals: + return None + return round(sum(vals) / len(vals), 3) + +def size_to_bytes(num: str, unit: str) -> int: + n = float(num) + u = unit.upper().rstrip("s") + mul = {"": 1, "B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + return int(n * mul.get(u, 1)) + +def parse_footprint(text: str): + phys = peak = None + m = re.search(r"phys_footprint:\s+([0-9.]+)\s*([A-Za-z]+)", text) + if m: + phys = size_to_bytes(m.group(1), m.group(2)) + m = re.search(r"phys_footprint_peak:\s+([0-9.]+)\s*([A-Za-z]+)", text) + if m: + peak = size_to_bytes(m.group(1), m.group(2)) + return phys, peak + +top_text = (raw_dir / "top.txt").read_text(errors="replace") if (raw_dir / "top.txt").exists() else "" +fp_text = (raw_dir / "footprint.txt").read_text(errors="replace") if (raw_dir / "footprint.txt").exists() else "" +phys, peak = parse_footprint(fp_text) +err = os.environ.get("MEASURE_POWER_ERR") or None + +payload = { + "date": os.environ["MEASURE_STAMP"], + "commit": os.environ["MEASURE_COMMIT"], + "mode": os.environ["MEASURE_MODE"], + "duration_seconds": int(os.environ["MEASURE_DURATION"]), + "pid": int(pid), + "chip": os.environ["MEASURE_CHIP"], + "os": os.environ["MEASURE_OS"], + "binary_bytes": int(os.environ["MEASURE_BIN"]), + "cpu_pct_avg": cpu_avg(top_text), + "footprint_bytes": phys, + "footprint_peak_bytes": peak, + "powermetrics_error": err, + "energy_impact": None, + "idle_wakeups_per_s": None, + "raw_dir": str(raw_dir), + "notes": [ + "Energy Impact is Activity Monitor / Instruments Energy Log; not sampled here.", + "Idle wake-ups need powermetrics or Activity Monitor.", + "Never invent numbers; fields are null when the sampler did not run.", + ], +} +out = os.environ["MEASURE_JSON"] +with open(out, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") +print("wrote", out) +PY + +echo "measure: stopping pid $PID" +kill "$PID" 2>/dev/null || true +sleep 0.5 +kill -9 "$PID" 2>/dev/null || true +wait "$PID" 2>/dev/null || true + +echo "measure: done" +cat "$JSON"