Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/performance/macos-interaction-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# macOS Interaction Baseline

Issue #490 provides the repeatable measurement entry point for the #467
performance work. The product stays unchanged unless the baseline environment
variable is set.

## Fixed matrix

| ID | Scenario | Fixture |
| --- | --- | --- |
| T | Continuous typing for 10 seconds, then pause for 2 seconds | 10 KiB, 500 KiB, 2 MiB |
| N | Open the large file and stay idle for 10 seconds | 500 KiB |
| D | Drag the sidebar, bottom bar, Git Log, Run, Tests, and Diff splitters | 500 KiB |
| S | Toggle Search Everywhere 20 cold and 20 warm times | 500 KiB |
| Term | Produce terminal output for 10 seconds | 500 KiB |
| R | Produce Run/Tests output for 10 seconds | 500 KiB |

## Run

List the matrix:

```sh
scripts/measure-macos-performance-baseline.sh --list
```

Run three Release startup sessions for a scenario and fixture:

```sh
scripts/measure-macos-performance-baseline.sh \
--scenario T \
--fixture 500KiB \
--runs 3
```

For the interactive scenarios, keep the process alive while carrying out the
actions in the table:

```sh
scripts/measure-macos-performance-baseline.sh \
--scenario D \
--fixture 500KiB \
--runs 3 \
--interactive
```

The script writes a TSV report and prints the median resident set size. During
baseline runs, the same four interactions also emit
`LITHE_PERF_SIGNPOST` lines to the captured process log. The TSV includes
count, p50, p95, and max duration for `editor.input`, `appmodel.relay`,
`split.drag`, and `search.everywhere`. Instruments can still be used for
system-level hitches, but the report does not depend on Instruments exporting
application signposts. The baseline mode disables `FrameRateMonitor`, whose
per-vsync MainActor task would otherwise pollute the interaction trace.
18 changes: 17 additions & 1 deletion macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ struct LitheApp: App {

init() {
let store = MacUserDefaultsStore()
if LithePerformanceBaseline.isEnabled {
LitheSignpost.configureBaselineOutput { line in
FileHandle.standardError.write(Data(line.utf8))
}
}
let settings = AppSettings(
store: store,
logDirectoryProvider: MacServiceContainer.makeLogDirectoryProvider()
Expand Down Expand Up @@ -325,11 +330,13 @@ struct LitheApp: App {
startedAt: litheProcessLaunchDate,
baselineReporter: { marker in
Self.appendApplicationLog(applicationLogWriter, message: marker + "\n")
Self.emitPerformanceBaselineMarker(marker)
},
logsPerformanceBaseline: ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1",
processRegistry: processRegistry,
memorySampler: MacProcessMemorySampler()
))
Self.emitPerformanceBaselineMarker(LithePerformanceBaseline.configurationMarker())
let updateChecker = UpdateChecker()
_updateChecker = StateObject(wrappedValue: updateChecker)
appDelegate.projectSessions = projectSessions
Expand Down Expand Up @@ -376,6 +383,13 @@ struct LitheApp: App {
}
}

private static func emitPerformanceBaselineMarker(_ marker: String) {
guard LithePerformanceBaseline.isEnabled else { return }
let data = Data((marker + "\n").utf8)
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.synchronizeFile()
}

private var model: AppModel { projectSessions.activeModel }

var body: some Scene {
Expand All @@ -396,7 +410,9 @@ struct LitheApp: App {
.preferredColorScheme(settings.themePreference.preferredColorScheme)
.task {
memoryUsageMonitor.start()
frameRateMonitor.start()
if !LithePerformanceBaseline.isEnabled {
frameRateMonitor.start()
}
}
}
.defaultSize(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ extension AppModel {
}

func searchEverywhere(options: ProjectSearchOptions = .default) async {
let signpost = LitheSignpost.begin("search.everywhere")
defer { LitheSignpost.end("search.everywhere", signpost) }
guard let searchFeature = await activateSearchModule() else { return }
guard let workspaceURL else { searchFeature.clearSearchEverywhere(); return }
let query = searchEverywhereQuery
Expand Down
2 changes: 2 additions & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ final class AppModel: ObservableObject, Identifiable {
func scheduleObjectWillChangeRelay() {
guard !isObjectWillChangeRelayScheduled else { return }
isObjectWillChangeRelayScheduled = true
let signpost = LitheSignpost.begin("appmodel.relay")
Task { @MainActor [weak self] in
defer { LitheSignpost.end("appmodel.relay", signpost) }
guard let self else { return }
self.isObjectWillChangeRelayScheduled = false
self.objectWillChange.send()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

enum LithePerformanceScenario: String, CaseIterable, Codable, Sendable {
case typing = "T"
case navigation = "N"
case dragging = "D"
case search = "S"
case terminal = "Term"
case runOutput = "R"

var title: String {
switch self {
case .typing:
"连续输入"
case .navigation:
"打开大文件后空闲"
case .dragging:
"连续拖动分栏"
case .search:
"Search Everywhere"
case .terminal:
"终端高频输出"
case .runOutput:
"Run/Tests 输出"
}
}
}

struct LithePerformanceFixture: Codable, Equatable, Sendable {
let label: String
let byteCount: Int

static let all: [LithePerformanceFixture] = [
LithePerformanceFixture(label: "10KiB", byteCount: 10 * 1024),
LithePerformanceFixture(label: "500KiB", byteCount: 500 * 1024),
LithePerformanceFixture(label: "2MiB", byteCount: 2 * 1024 * 1024)
]
}

enum LithePerformanceBaseline {
static var isEnabled: Bool {
ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1"
}

static func configurationMarker(environment: [String: String] = ProcessInfo.processInfo.environment) -> String {
let scenario = environment["LITHE_PERFORMANCE_SCENARIO"] ?? "unknown"
let fixtureBytes = environment["LITHE_PERFORMANCE_FIXTURE_BYTES"] ?? "unknown"
return "LITHE_BASELINE_CONFIG scenario=\(scenario) fixture_bytes=\(fixtureBytes) fps_monitor=disabled"
}

}
38 changes: 34 additions & 4 deletions macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import Foundation
import os

enum LitheSignpost {
struct State {
let osState: OSSignpostIntervalState
let startedAt: UInt64
let name: String
}

private static let signposter = OSSignposter(
subsystem: "com.openres.Lithe",
category: "Rendering"
)
private static let baselineEnabled =
ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1"
private static var baselineOutput: ((String) -> Void)?

static func begin(_ name: StaticString) -> OSSignpostIntervalState {
signposter.beginInterval(name)
static func configureBaselineOutput(_ output: @escaping (String) -> Void) {
baselineOutput = output
}

static func end(_ name: StaticString, _ state: OSSignpostIntervalState) {
signposter.endInterval(name, state)
static func begin(_ name: StaticString) -> State {
State(
osState: signposter.beginInterval(name),
startedAt: DispatchTime.now().uptimeNanoseconds,
name: "\(name)"
)
}

static func end(_ name: StaticString, _ state: State) {
signposter.endInterval(name, state.osState)
guard baselineEnabled else { return }

let durationNanoseconds = DispatchTime.now().uptimeNanoseconds - state.startedAt
let durationMilliseconds = Double(durationNanoseconds) / 1_000_000
// Baseline runs need a portable fallback because Instruments may omit
// application signposts even when the OS signpost API is active.
let line = String(
format: "LITHE_PERF_SIGNPOST name=%@ duration_ms=%.3f\n",
state.name,
durationMilliseconds
)
baselineOutput?(line)
}

#if DEBUG
Expand Down
1 change: 1 addition & 0 deletions macos/Sources/Lithe/Views/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ struct RootView: View {
.task {
guard !didStartAutomaticUpdateCheck else { return }
didStartAutomaticUpdateCheck = true
guard !LithePerformanceBaseline.isEnabled else { return }
await updateChecker.checkForUpdates()
}
}
Expand Down
2 changes: 2 additions & 0 deletions macos/Sources/Lithe/Views/Editor/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,8 @@ struct CodeEditorView: NSViewRepresentable {
}

func textDidChange(_ notification: Notification) {
let signpost = LitheSignpost.begin("editor.input")
defer { LitheSignpost.end("editor.input", signpost) }
guard let textView else { return }
guard document?.isReadOnly != true else { return }
let codeTextView = textView as? CodeTextView
Expand Down
11 changes: 11 additions & 0 deletions macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import os
import SwiftUI

enum LitheSplitAxis {
Expand All @@ -22,6 +23,7 @@ struct SplitHandleView: View {
@State private var isHovering = false
@State private var isDragging = false
@State private var dragScheduler = LitheDragUpdateScheduler()
@State private var dragSignpost: LitheSignpost.State?
@State private var cursor = SplitHandleCursor()

init(
Expand Down Expand Up @@ -61,6 +63,7 @@ struct SplitHandleView: View {
if !isDragging {
isDragging = true
cursor.update(isResizing: true, cursor: resizeCursor)
dragSignpost = LitheSignpost.begin("split.drag")
onDragStarted()
}
let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height
Expand All @@ -79,6 +82,10 @@ struct SplitHandleView: View {
dragScheduler.cancel()
isDragging = false
cursor.update(isResizing: isHovering, cursor: resizeCursor)
if let dragSignpost {
LitheSignpost.end("split.drag", dragSignpost)
self.dragSignpost = nil
}
onDragEnded(finalTranslation)
}
)
Expand All @@ -89,6 +96,10 @@ struct SplitHandleView: View {
}
.onDisappear {
dragScheduler.cancel()
if let dragSignpost {
LitheSignpost.end("split.drag", dragSignpost)
self.dragSignpost = nil
}
cursor.update(isResizing: false, cursor: resizeCursor)
}
.help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize")
Expand Down
27 changes: 27 additions & 0 deletions macos/Tests/LitheTests/LithePerformanceBaselineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import Testing
@testable import Lithe

@Suite("macOS performance baseline definitions")
struct LithePerformanceBaselineTests {
@Test
func keepsTheThreeEditorFixtureSizesStable() {
#expect(LithePerformanceFixture.all.map(\.label) == ["10KiB", "500KiB", "2MiB"])
#expect(LithePerformanceFixture.all.map(\.byteCount) == [10 * 1024, 500 * 1024, 2 * 1024 * 1024])
}

@Test
func keepsTheScenarioOrderStableForReports() {
#expect(LithePerformanceScenario.allCases.map(\.rawValue) == ["T", "N", "D", "S", "Term", "R"])
}

@Test
func configurationMarkerIncludesTheMeasurementIdentity() {
let marker = LithePerformanceBaseline.configurationMarker(environment: [
"LITHE_PERFORMANCE_SCENARIO": "T",
"LITHE_PERFORMANCE_FIXTURE_BYTES": "512000"
])

#expect(marker == "LITHE_BASELINE_CONFIG scenario=T fixture_bytes=512000 fps_monitor=disabled")
}
}
Loading
Loading