From d74644be9e9776a7aee72d75d4a495c5e1482a04 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:06:18 +0200 Subject: [PATCH 1/2] Measure the manual workout sample path with synthetic Release benchmarks --- Strand/App/AppModel.swift | 4 +- StrandTests/WorkoutSampleBenchmarkTests.swift | 67 +++++++++++++++++++ docs/FORK_TRAINING_PERFORMANCE.md | 48 +++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 StrandTests/WorkoutSampleBenchmarkTests.swift create mode 100644 docs/FORK_TRAINING_PERFORMANCE.md diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 71b62e6f26..c5761208de 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1094,12 +1094,12 @@ final class AppModel: ObservableObject { /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called /// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. - private func captureWorkoutSample() { + func captureWorkoutSample(at date: Date = Date()) { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } // A second that already has its sample moves only the peak: publish that, and skip the rescore and the // snapshot (the next second's sample carries the peak into the snapshot). let peakBefore = w.peakHr - guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else { + guard w.recordSample(HRSample(ts: Int(date.timeIntervalSince1970), bpm: hr)) else { if w.peakHr != peakBefore { activeWorkout = w } return } diff --git a/StrandTests/WorkoutSampleBenchmarkTests.swift b/StrandTests/WorkoutSampleBenchmarkTests.swift new file mode 100644 index 0000000000..ddd6e7322a --- /dev/null +++ b/StrandTests/WorkoutSampleBenchmarkTests.swift @@ -0,0 +1,67 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleBenchmarkTests: XCTestCase { + func testReleaseBenchmark() async throws { + guard !_isDebugAssertConfiguration() else { throw XCTSkip("Run this benchmark in Release.") } + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.bpm = 150 + for count in [0, 600, 3_600, 7_200] { + let start = 1_700_000_000 + let seed = (0.. 0 { samples[count - 1] = HRSample(ts: start + count + trial, bpm: 150) } + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: Double(start))) + workout.samples = samples + workout.sessionID = "synthetic-benchmark" + let (score, scoringMs) = timed { + StrainScorer.strain(samples, maxHR: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0 + } + workout.liveStrain = score + let snapshot = ActiveWorkoutPersistence.Snapshot( + startSec: start, sport: workout.sport, samples: samples, + avgHr: workout.avgHr, peakHr: workout.peakHr, liveStrain: score, + pausedDurationSec: 0, sessionID: workout.sessionID, + pausedForPulseLoss: false, completedPauses: []) + let (data, encodingMs) = timed { ActiveWorkoutPersistence.encode(snapshot) } + XCTAssertNotNil(data) + model.activeWorkout = workout + let date = Date(timeIntervalSince1970: Double(start + count + trial + 1)) + let (_, captureMs) = timed { model.captureWorkoutSample(at: date) } + XCTAssertEqual(model.activeWorkout?.samples.count, count + 1) + if trial >= 5 { + scoreTimes.append(scoringMs) + encodeTimes.append(encodingMs) + captureTimes.append(captureMs) + } + } + print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes))") + } + } + + private func timed(_ body: () -> T) -> (T, Double) { + let start = DispatchTime.now().uptimeNanoseconds + let value = autoreleasepool(invoking: body) + return (value, Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000) + } + + private func summary(_ times: [Double]) -> String { + let sorted = times.sorted() + return String(format: "%.4f/%.4f", sorted[sorted.count / 2], sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1]) + } +} diff --git a/docs/FORK_TRAINING_PERFORMANCE.md b/docs/FORK_TRAINING_PERFORMANCE.md new file mode 100644 index 0000000000..ba3d185a23 --- /dev/null +++ b/docs/FORK_TRAINING_PERFORMANCE.md @@ -0,0 +1,48 @@ +# Manual workout sample processing + +The local build-450 recording contains 105 seconds of foreground use during a normal +sport recording, including a 518 ms callback interval on Today. It measures delayed +display-link callbacks, without call stacks that would attribute those delays to a +particular function. Raw device logs remain in ignored local storage. + +The synchronous sample path currently calculates a mean, fingerprints and scores the +growing pulse window, then JSON-encodes and stores the entire workout snapshot on the +main actor. The benchmark calls those production functions with synthetic samples. + +## Reproducing the baseline + +```sh +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme Strand -configuration Release \ + -destination 'platform=macOS,arch=arm64' -derivedDataPath build/training-macos-release \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES ENABLE_TESTABILITY=YES \ + 'OTHER_SWIFT_FLAGS=$(inherited) -D DEBUG' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \ + -only-testing:StrandTests/WorkoutSampleBenchmarkTests test +``` + +The existing test bundle requires DEBUG-only test accessors. The command enables +those accessors while retaining Release optimization (`-O`); the measured capture, +scoring and encoding functions have no DEBUG alternative. Device builds use ordinary +Release settings. The benchmark skips unoptimized Debug builds. + +Each size gets five warmups and 101 measured iterations. Nonempty scoring windows +have a different fingerprint on each iteration, so scoring includes a cache miss. +Scoring and encoding are timed separately. Capture is the complete synchronous +`AppModel.captureWorkoutSample` call, including publisher callbacks and persistence. +Its input starts with the listed number of samples and accepts one additional sample. +Initialization, fixture creation and result assertions are outside the timed calls. + +| Starting samples | Scoring median / p95 | Encoding median / p95 | Synchronous capture median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0053 / 0.0065 ms | 0.0039 / 0.0065 ms | 0.1702 / 0.2253 ms | +| 600 | 0.0078 / 0.0099 ms | 0.3110 / 0.3394 ms | 0.4922 / 0.5579 ms | +| 3,600 | 0.0203 / 0.0215 ms | 1.8089 / 1.8705 ms | 2.1028 / 2.1775 ms | +| 7,200 | 0.0353 / 0.0395 ms | 3.6312 / 3.7570 ms | 4.1668 / 4.2842 ms | + +Encoding accounts for most of this measured cost. Moving it off the main actor is +worth testing, but these numbers do not explain the full 518 ms device delay. + +These are local CPU costs on an Apple M4 Max with Xcode 27.0. They do not measure +iPhone scrolling, rendered frames, GPU work or physical disk durability. UserDefaults +writes retain the platform's existing persistence semantics. From 58c9e9d72a870fb4cea68de2879ab1e298af34cd Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:26 +0200 Subject: [PATCH 2/2] Move manual workout scoring and snapshot encoding off the main actor --- Strand/App/ActiveWorkoutPersistence.swift | 7 +- Strand/App/AppModel.swift | 103 +++++++--- Strand/App/StrandApp.swift | 1 + StrandTests/OfflineWorkoutSaveTests.swift | 30 ++- StrandTests/WorkoutSampleBenchmarkTests.swift | 17 +- .../WorkoutSampleProcessingTests.swift | 176 ++++++++++++++++++ StrandiOS/App/StrandiOSApp.swift | 1 + docs/FORK_TRAINING_PERFORMANCE.md | 53 +++++- 8 files changed, 345 insertions(+), 43 deletions(-) create mode 100644 StrandTests/WorkoutSampleProcessingTests.swift diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index 61eefa90fa..586a3ac787 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -10,8 +10,9 @@ import WhoopProtocol /// records a route too, and its fixes are banked by `ActiveRouteStore` rather than by this snapshot, /// which stays the cheap per-sample write it was built as.) This is the Apple analogue of Android's /// `ActiveWorkoutStore`/`ActiveWorkoutPersistence`: a tiny `Codable` snapshot (start time, sport, the -/// accumulated HR samples + running stats) is written to `UserDefaults` on start and on every captured -/// sample, and read back on launch so an interrupted session can still be ended and saved. +/// accumulated HR samples + running stats) is checkpointed on start and lifecycle transitions. Captured +/// samples queue scoring and encoding off the main actor before the snapshot is written to `UserDefaults`. +/// It is read back on launch so an interrupted session can still be ended and saved. /// /// On-device only; mirrors the existing `moments` / `sleepMarks` `UserDefaults` persistence in `AppModel`. /// The encode/decode is pure (no `UserDefaults` dependency on the codec itself) so the persist/rehydrate @@ -69,7 +70,7 @@ enum ActiveWorkoutPersistence { ) } - /// Persist (overwrite) the snapshot. Cheap; called on start + each captured sample. + /// Persist (overwrite) a lifecycle checkpoint synchronously. static func store(_ snapshot: Snapshot, into defaults: UserDefaults = .standard) { guard let data = encode(snapshot) else { return } defaults.set(data, forKey: defaultsKey) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index c5761208de..ee653a98bb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -110,6 +110,25 @@ final class AppModel: ObservableObject { /// Android's `ActiveWorkout.gpsEnabled`. private var activeWorkoutIsGps = false + private(set) var workoutSampleRevision: UInt64 = 0 + private(set) var workoutSampleTask: Task? + private var pendingWorkoutSample: WorkoutSampleWork? + + struct WorkoutSampleWork { + let revision: UInt64 + let snapshot: ActiveWorkoutPersistence.Snapshot + let hrMax: Double + let method: StrainScorer.Method + let sex: String + + func process() -> (strain: Double, data: Data?) { + var scored = snapshot + scored.liveStrain = StrainScorer.strain(scored.samples, maxHR: hrMax, + method: method, sex: sex) ?? 0 + return (scored.liveStrain, ActiveWorkoutPersistence.encode(scored)) + } + } + /// A manual workout in progress. `samples` accumulate from the smoothed live `bpm`; `liveStrain` /// is recomputed as the window grows so the active card can show strain building in real time. struct ActiveWorkout: Equatable { @@ -155,6 +174,15 @@ final class AppModel: ObservableObject { /// second folded into `peakHr` (`recordSample`). Nil with no samples. var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } } + var snapshot: ActiveWorkoutPersistence.Snapshot { + ActiveWorkoutPersistence.Snapshot( + startSec: Int(start.timeIntervalSince1970), sport: sport, samples: samples, + avgHr: avgHr, peakHr: peakHr, liveStrain: liveStrain, + pausedAtSec: pausedAt.map { Int($0.timeIntervalSince1970) }, + pausedDurationSec: Int(pausedDuration), sessionID: sessionID, + pausedForPulseLoss: pausedForPulseLoss, completedPauses: completedPauses) + } + /// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again. func elapsed(at now: Date = Date()) -> TimeInterval { ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt, @@ -887,25 +915,12 @@ final class AppModel: ObservableObject { domain: .dataImport) } - /// Persist the in-flight manual workout to `UserDefaults` so it survives the app being killed mid- - /// session (#529). Called on start + each captured sample. A no-op when nothing is running. The Apple - /// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its - /// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay - /// a small per-sample write instead of rewriting a growing route on every beat. - private func persistActiveWorkout() { + /// Bank the latest raw window at a lifecycle boundary, before the app can be suspended. + func persistActiveWorkout() { + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil guard let w = activeWorkout else { return } - ActiveWorkoutPersistence.store( - ActiveWorkoutPersistence.Snapshot( - startSec: Int(w.start.timeIntervalSince1970), - sport: w.sport, - samples: w.samples, - avgHr: w.avgHr, - peakHr: w.peakHr, - liveStrain: w.liveStrain, - pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) }, - pausedDurationSec: Int(w.pausedDuration), - sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss, - completedPauses: w.completedPauses)) + ActiveWorkoutPersistence.store(w.snapshot) } /// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable @@ -974,6 +989,8 @@ final class AppModel: ObservableObject { private func clearActiveWorkout() { guard activeWorkout != nil else { return } + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil activeWorkout = nil if activeWorkoutIsGps { gpsRecorder.stop() } activeWorkoutIsGps = false @@ -1091,25 +1108,49 @@ final class AppModel: ObservableObject { await repo.refresh() } - /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called - /// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain - /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. - func captureWorkoutSample(at date: Date = Date()) { + /// Keep every accepted sample; score and encode immutable snapshots off the main actor. + func captureWorkoutSample(at date: Date? = nil) { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } - // A second that already has its sample moves only the peak: publish that, and skip the rescore and the - // snapshot (the next second's sample carries the peak into the snapshot). let peakBefore = w.peakHr - guard w.recordSample(HRSample(ts: Int(date.timeIntervalSince1970), bpm: hr)) else { - if w.peakHr != peakBefore { activeWorkout = w } + guard w.recordSample(HRSample(ts: Int((date ?? Date()).timeIntervalSince1970), bpm: hr)) else { + if w.peakHr != peakBefore { + activeWorkout = w + queueWorkoutSample(w) + } return } w.peakHr = max(w.peakHr, hr) - w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded()) - w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax), - method: PuffinExperiment.effortMethod, sex: profile.sex) ?? 0 + w.avgHr = Int((Double(w.samples.reduce(0) { $0 + $1.bpm }) / Double(w.samples.count)).rounded()) activeWorkout = w - // Re-snapshot the durable session so a kill keeps the latest accumulated HR window (#529). - persistActiveWorkout() + queueWorkoutSample(w) + } + + private func queueWorkoutSample(_ workout: ActiveWorkout) { + workoutSampleRevision &+= 1 + pendingWorkoutSample = WorkoutSampleWork(revision: workoutSampleRevision, + snapshot: workout.snapshot, hrMax: Double(profile.hrMax), + method: PuffinExperiment.effortMethod, sex: profile.sex) + guard workoutSampleTask == nil else { return } + workoutSampleTask = Task { [weak self] in + while let work = self?.pendingWorkoutSample { + self?.pendingWorkoutSample = nil + let result = await Task.detached(priority: .utility) { work.process() }.value + self?.applyWorkoutSample(work, result: result) + } + self?.workoutSampleTask = nil + } + } + + func applyWorkoutSample(_ work: WorkoutSampleWork, result: (strain: Double, data: Data?)) { + guard work.revision == workoutSampleRevision, var workout = activeWorkout, + workout.sessionID == work.snapshot.sessionID, !workout.isPaused else { return } + if workout.liveStrain != result.strain { + workout.liveStrain = result.strain + activeWorkout = workout + } + if let data = result.data { + UserDefaults.standard.set(data, forKey: ActiveWorkoutPersistence.defaultsKey) + } } /// Drop the smoothing window and blank the hero number so a resume / re-attach shows "," diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 588e83a2e4..cd78e18b33 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -78,6 +78,7 @@ struct StrandApp: App { // this target is macOS 13. .onChange(of: scenePhase) { phase in if phase == .active { model.ble.requestSync(.foreground) } + if phase == .background { model.persistActiveWorkout() } } } .windowStyle(.hiddenTitleBar) diff --git a/StrandTests/OfflineWorkoutSaveTests.swift b/StrandTests/OfflineWorkoutSaveTests.swift index dc2ad43c87..ff4f95c1ee 100644 --- a/StrandTests/OfflineWorkoutSaveTests.swift +++ b/StrandTests/OfflineWorkoutSaveTests.swift @@ -1,10 +1,20 @@ import XCTest import WhoopStore +import WhoopProtocol @testable import Strand @MainActor final class OfflineWorkoutSaveTests: XCTestCase { func testNoPulseOrGPSStillSavesAndFailedWriteKeepsTheSessionRetryable() async throws { + try await assertFailedWriteIsRetryable(samples: []) + } + + func testFailedWriteRetainsTheLatestSamplesWhileAWorkerIsPending() async throws { + let started = Int(Date().timeIntervalSince1970) - 600 + try await assertFailedWriteIsRetryable(samples: (0..<600).map { HRSample(ts: started + $0, bpm: 150) }) + } + + private func assertFailedWriteIsRetryable(samples: [HRSample]) async throws { let defaults = UserDefaults.standard let keys = [ActiveWorkoutPersistence.defaultsKey, RecordedWorkoutHistory.defaultsKey] let originals = keys.map { ($0, defaults.object(forKey: $0)) } @@ -25,12 +35,19 @@ final class OfflineWorkoutSaveTests: XCTestCase { model.repo.setStoreForTesting(store) let started = Date().addingTimeInterval(-600) model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + for sample in samples { + model.bpm = sample.bpm + model.captureWorkoutSample(at: Date(timeIntervalSince1970: Double(sample.ts))) + } let id = try XCTUnwrap(model.activeWorkout?.sessionID) try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {} XCTAssertEqual(model.activeWorkout?.sessionID, id) XCTAssertTrue(model.activeWorkout?.isPaused == true) XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples, samples) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples, samples) try execute("DROP TRIGGER fail_workout_save", path: path) try await model.finishWorkout() try await model.finishWorkout() @@ -42,9 +59,16 @@ final class OfflineWorkoutSaveTests: XCTestCase { let row = try XCTUnwrap(rows.first) XCTAssertEqual(row.sport, "Strength") XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) - XCTAssertNil(row.avgHr) - XCTAssertNil(row.strain) - XCTAssertNil(row.energyKcal) + if samples.isEmpty { + XCTAssertNil(row.avgHr) + XCTAssertNil(row.strain) + XCTAssertNil(row.energyKcal) + } else { + XCTAssertEqual(row.avgHr, 150) + XCTAssertEqual(row.maxHr, 150) + XCTAssertNotNil(row.strain) + XCTAssertNotNil(row.energyKcal) + } XCTAssertTrue(RecordedWorkoutHistory.load().contains { $0.matches(row, deviceId: model.deviceId) }) } diff --git a/StrandTests/WorkoutSampleBenchmarkTests.swift b/StrandTests/WorkoutSampleBenchmarkTests.swift index ddd6e7322a..d5c70d0f6a 100644 --- a/StrandTests/WorkoutSampleBenchmarkTests.swift +++ b/StrandTests/WorkoutSampleBenchmarkTests.swift @@ -21,6 +21,7 @@ final class WorkoutSampleBenchmarkTests: XCTestCase { let start = 1_700_000_000 let seed = (0..= 5 { scoreTimes.append(scoringMs) encodeTimes.append(encodingMs) captureTimes.append(captureMs) + mergeTimes.append(mergeMs) + mainTimes.append(captureMs + mergeMs) } } - print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes))") + print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes)) merge=\(summary(mergeTimes)) main=\(summary(mainTimes))") + if count == 7_200 { + XCTAssertLessThan(mainTimes.sorted()[Int(ceil(Double(mainTimes.count) * 0.95)) - 1], 2, + "Remaining capture + result merge must stay under 2 ms p95.") + } } } private func timed(_ body: () -> T) -> (T, Double) { let start = DispatchTime.now().uptimeNanoseconds - let value = autoreleasepool(invoking: body) + let value = ObjectiveC.autoreleasepool(invoking: body) return (value, Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000) } diff --git a/StrandTests/WorkoutSampleProcessingTests.swift b/StrandTests/WorkoutSampleProcessingTests.swift new file mode 100644 index 0000000000..64f1176bc4 --- /dev/null +++ b/StrandTests/WorkoutSampleProcessingTests.swift @@ -0,0 +1,176 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleProcessingTests: XCTestCase { + func testCoalescingRetainsEverySampleAndMatchesTheOriginalStats() async { + await withModel { model in + var expected: [HRSample] = [] + for second in 0..<650 { + let sample = HRSample(ts: 1_700_000_000 + second + (second / 120) * 45, + bpm: second < 200 ? 150 : 110 + second % 65) + expected.append(sample) + model.bpm = sample.bpm + let date = Date(timeIntervalSince1970: Double(sample.ts)) + model.captureWorkoutSample(at: date) + model.bpm = sample.bpm + 5 + model.captureWorkoutSample(at: date) + } + await model.workoutSampleTask?.value + let workout = model.activeWorkout + XCTAssertEqual(workout?.samples, expected) + XCTAssertEqual(workout?.avgHr, Int((Double(expected.map(\.bpm).reduce(0, +)) / Double(expected.count)).rounded())) + XCTAssertEqual(workout?.peakHr, expected.map(\.bpm).max()! + 5) + XCTAssertEqual(workout?.liveStrain, StrainScorer.strain(expected, + maxHR: Double(model.profile.hrMax), method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), workout?.snapshot) + XCTAssertNil(model.workoutSampleTask) + } + } + + func testWorkerPreservesBothEffortMethodsAndTheSnapshot() async { + await withModel { model in + for method in [StrainScorer.Method.edwards, .banister] { + for sex in ["male", "female"] { + let samples = (0..<650).map { HRSample(ts: 1_700_000_000 + $0 * 2, bpm: 110 + $0 % 65) } + var workout = model.activeWorkout! + workout.samples = samples + workout.avgHr = Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) + workout.peakHr = 185 + workout.completedPauses = [DateInterval(start: workout.start, duration: 20)] + let work = AppModel.WorkoutSampleWork(revision: 1, snapshot: workout.snapshot, + hrMax: 190, method: method, sex: sex) + let result = work.process() + let expected = StrainScorer.strain(samples, maxHR: 190, method: method, sex: sex) ?? 0 + XCTAssertEqual(result.strain, expected) + var snapshot = workout.snapshot + snapshot.liveStrain = expected + XCTAssertEqual(ActiveWorkoutPersistence.decode(result.data), snapshot) + } + } + } + } + + func testLateResultCannotUndoPause() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateSampleResultCannotOverwriteANewerWindow() async { + await withModel { model in + model.persistActiveWorkout() + let checkpoint = ActiveWorkoutPersistence.load() + let work = captureWork(model) + let result = work.process() + model.bpm = 160 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), checkpoint) + await model.workoutSampleTask?.value + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotCrossResumeAndTheNextSampleStillIncludesTheFullWindow() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.resumeWorkout(at: Date(timeIntervalSince1970: 1_700_000_631)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertFalse(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_632)) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.pausedDuration, 30) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotReplaceANewSession() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.discardWorkout() + model.startWorkout(sport: "Strength") + let id = model.activeWorkout?.sessionID + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNotEqual(id, work.snapshot.sessionID) + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertEqual(model.activeWorkout?.samples.count, 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotResurrectAFinishedSession() async { + await withModel { model in + model.activeWorkout = AppModel.ActiveWorkout(start: Date()) + let work = captureWork(model) + let result = work.process() + do { try await model.finishWorkout() } catch { XCTFail("\(error)") } + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + } + } + + func testCheckpointBanksTheLatestWindowAndRejectsAnOlderResult() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.persistActiveWorkout() + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples.count, 601) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + let restored = AppModel() + XCTAssertEqual(restored.activeWorkout?.snapshot, model.activeWorkout?.snapshot) + } + } + + private func captureWork(_ model: AppModel) -> AppModel.WorkoutSampleWork { + var workout = model.activeWorkout! + workout.samples = (0..<600).map { HRSample(ts: 1_700_000_000 + $0, bpm: 150) } + workout.avgHr = 150 + workout.peakHr = 150 + workout.liveStrain = 12 + model.activeWorkout = workout + model.bpm = 150 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_600)) + return AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision, + snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) + } + + private func withModel(_ check: (AppModel) async -> Void) async { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.activeWorkout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_700_000_000)) + await check(model) + await model.workoutSampleTask?.value + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index e06a838a02..36b70decde 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -380,6 +380,7 @@ struct StrandiOSApp: App { await watch.pushLatest(from: model) } } else if phase == .background { + model.persistActiveWorkout() training.reconcile() // Re-submit on every transition because iOS may discard an old best-effort request. HealthWritebackBackgroundScheduler.updateSchedule( diff --git a/docs/FORK_TRAINING_PERFORMANCE.md b/docs/FORK_TRAINING_PERFORMANCE.md index ba3d185a23..e8d8e6ce4d 100644 --- a/docs/FORK_TRAINING_PERFORMANCE.md +++ b/docs/FORK_TRAINING_PERFORMANCE.md @@ -5,9 +5,9 @@ sport recording, including a 518 ms callback interval on Today. It measures dela display-link callbacks, without call stacks that would attribute those delays to a particular function. Raw device logs remain in ignored local storage. -The synchronous sample path currently calculates a mean, fingerprints and scores the -growing pulse window, then JSON-encodes and stores the entire workout snapshot on the -main actor. The benchmark calls those production functions with synthetic samples. +The baseline sample path calculated a mean, fingerprinted and scored the growing +pulse window, then JSON-encoded and stored the entire workout snapshot on the main +actor. The benchmark calls those production functions with synthetic samples. ## Reproducing the baseline @@ -28,7 +28,7 @@ Release settings. The benchmark skips unoptimized Debug builds. Each size gets five warmups and 101 measured iterations. Nonempty scoring windows have a different fingerprint on each iteration, so scoring includes a cache miss. -Scoring and encoding are timed separately. Capture is the complete synchronous +Scoring and encoding are timed separately. Baseline capture is the complete synchronous `AppModel.captureWorkoutSample` call, including publisher callbacks and persistence. Its input starts with the listed number of samples and accepts one additional sample. Initialization, fixture creation and result assertions are outside the timed calls. @@ -43,6 +43,51 @@ Initialization, fixture creation and result assertions are outside the timed cal Encoding accounts for most of this measured cost. Moving it off the main actor is worth testing, but these numbers do not explain the full 518 ms device delay. +## After moving scoring and encoding off the main actor + +The mean uses a single reduction without an intermediate array. A main-actor task +awaits one detached scoring-and-encoding job at a time, with one latest pending +snapshot. Samples stay in `activeWorkout`; coalescing replaces derived work, without +dropping readings. Higher peaks from duplicate seconds also replace pending work. +Results must match both session ID and revision. Start, pause, resume, discard and +background checkpoints invalidate older results. Persistence retains its key and +Codable shape, with no added checkpoint timer. Lifecycle checkpoints synchronously +bank the full window and its latest completed Effort estimate; final saving still +recomputes Effort using the existing resting-HR input. + +The updated benchmark times capture and a valid result merge separately. The merge +includes publishing a changed Effort estimate and `UserDefaults.set` of encoded data. +Preparation and computation of that result are outside these main-actor timings. +The table sums capture and merge for each trial before taking its median and p95. +The production worker drains before the next trial. Actor scheduling and SwiftUI +rendering are outside these function timings. A 7,200-sample p95 above 2 ms fails the +opt-in Release benchmark. + +| Starting samples | Capture median / p95 | Result merge median / p95 | Main-actor total median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0145 / 0.0170 ms | 0.3911 / 0.4361 ms | 0.4064 / 0.4535 ms | +| 600 | 0.0150 / 0.0190 ms | 0.3911 / 0.4522 ms | 0.4051 / 0.4676 ms | +| 3,600 | 0.0175 / 0.0255 ms | 0.3996 / 0.4639 ms | 0.4177 / 0.4837 ms | +| 7,200 | 0.0218 / 0.0355 ms | 0.5859 / 0.6795 ms | 0.6102 / 0.7050 ms | + +The same isolated scoring calls after the change measured median / p95 of +0.0219 / 0.0268, 0.0262 / 0.0332, 0.0393 / 0.0459 and 0.0551 / 0.0663 ms for the +four sizes. Encoding measured 0.0214 / 0.0259, 0.3445 / 0.3962, 1.8802 / 2.1310 and +3.8180 / 4.1372 ms. Those functions retain their algorithms and run off the main actor +in the sample path; this change targets main-actor occupancy rather than total CPU. + +At 7,200 starting samples, measured main-actor p95 falls from 4.2842 to 0.7050 ms, +about 84%. The 35 focused macOS tests pass, covering numeric and snapshot equivalence, +unchanged pulse values, duplicate seconds and measurement gaps; obsolete results +after newer samples, pause, resume, replacement, checkpoint and finish; and a failed +database write followed by a successful retry with every sample retained. + +No chart renderer, nightly analysis, global Today refresh, widget comparison or +ActivityKit throttle was changed. This is Apple app scheduling and encoding; the +shared numerical and storage contracts are unchanged. Android is outside this fork +change. An iPhone comparison after installation is still required before claiming +the recorded scrolling stalls are resolved. + These are local CPU costs on an Apple M4 Max with Xcode 27.0. They do not measure iPhone scrolling, rendered frames, GPU work or physical disk durability. UserDefaults writes retain the platform's existing persistence semantics.