perf(editor): add profiling benchmark harness
This commit is contained in:
parent
9730f860de
commit
be53eddfd2
6 changed files with 52178 additions and 2 deletions
51482
Docs/Benchmarks/5mb.md
Normal file
51482
Docs/Benchmarks/5mb.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "SaplingApp", targets: ["SaplingApp"]),
|
.executable(name: "SaplingApp", targets: ["SaplingApp"]),
|
||||||
|
.executable(name: "SaplingEditorBenchmark", targets: ["SaplingEditorBenchmark"]),
|
||||||
.library(name: "SaplingCore", targets: ["SaplingCore"]),
|
.library(name: "SaplingCore", targets: ["SaplingCore"]),
|
||||||
.library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]),
|
.library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]),
|
||||||
.library(name: "SaplingGit", targets: ["SaplingGit"]),
|
.library(name: "SaplingGit", targets: ["SaplingGit"]),
|
||||||
|
|
@ -33,6 +34,13 @@ let package = Package(
|
||||||
"SaplingUI"
|
"SaplingUI"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "SaplingEditorBenchmark",
|
||||||
|
dependencies: [
|
||||||
|
"SaplingCore",
|
||||||
|
"SaplingEditor"
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(name: "SaplingCore"),
|
.target(name: "SaplingCore"),
|
||||||
.target(
|
.target(
|
||||||
name: "SaplingWorkspace",
|
name: "SaplingWorkspace",
|
||||||
|
|
|
||||||
541
Sources/SaplingEditor/EditorPerformanceProfiling.swift
Normal file
541
Sources/SaplingEditor/EditorPerformanceProfiling.swift
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
import Foundation
|
||||||
|
import SaplingCore
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public enum EditorBenchmarkCategory: String, Hashable, Sendable {
|
||||||
|
case documentLoad
|
||||||
|
case editorUpdate
|
||||||
|
case rendering
|
||||||
|
case layout
|
||||||
|
case interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditorBenchmarkMeasurement: Hashable, Sendable {
|
||||||
|
public var name: String
|
||||||
|
public var category: EditorBenchmarkCategory
|
||||||
|
public var durationMilliseconds: Double
|
||||||
|
public var notes: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
category: EditorBenchmarkCategory,
|
||||||
|
durationMilliseconds: Double,
|
||||||
|
notes: String = ""
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.durationMilliseconds = durationMilliseconds
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditorBenchmarkDocumentProfile: Hashable, Sendable {
|
||||||
|
public var fileName: String
|
||||||
|
public var byteCount: Int
|
||||||
|
public var characterCount: Int
|
||||||
|
public var utf16Count: Int
|
||||||
|
public var lineCount: Int
|
||||||
|
public var headingCount: Int
|
||||||
|
public var unorderedListItemCount: Int
|
||||||
|
public var orderedListItemCount: Int
|
||||||
|
public var blockquoteCount: Int
|
||||||
|
public var fencedCodeFenceCount: Int
|
||||||
|
public var inlineCodeLineCount: Int
|
||||||
|
public var boldLineCount: Int
|
||||||
|
public var italicLineCount: Int
|
||||||
|
public var inlineLinkCount: Int
|
||||||
|
public var referenceLinkLikeCount: Int
|
||||||
|
public var imageCount: Int
|
||||||
|
public var maxLineUTF16Length: Int
|
||||||
|
public var averageLineUTF16Length: Double
|
||||||
|
|
||||||
|
public var markdownStructureSummary: String {
|
||||||
|
[
|
||||||
|
"\(headingCount) ATX headings",
|
||||||
|
"\(unorderedListItemCount) unordered list items",
|
||||||
|
"\(orderedListItemCount) ordered list items",
|
||||||
|
"\(blockquoteCount) blockquotes",
|
||||||
|
"\(fencedCodeFenceCount) fenced code fences",
|
||||||
|
"\(inlineCodeLineCount) lines with inline code",
|
||||||
|
"\(boldLineCount) lines with bold markers",
|
||||||
|
"\(italicLineCount) lines with italic markers",
|
||||||
|
"\(inlineLinkCount) inline links",
|
||||||
|
"\(referenceLinkLikeCount) reference-style link markers",
|
||||||
|
"\(imageCount) images"
|
||||||
|
].joined(separator: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditorBenchmarkResult: Sendable {
|
||||||
|
public var document: EditorBenchmarkDocumentProfile
|
||||||
|
public var measurements: [EditorBenchmarkMeasurement]
|
||||||
|
public var renderedModeTrace: EditorRenderedModeTrace
|
||||||
|
|
||||||
|
public var measuredTotalMilliseconds: Double {
|
||||||
|
measurements.reduce(0) { $0 + $1.durationMilliseconds }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hottestMeasurements: [EditorBenchmarkMeasurement] {
|
||||||
|
measurements.sorted { $0.durationMilliseconds > $1.durationMilliseconds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditorRenderedModeTrace: Hashable, Sendable {
|
||||||
|
public var totalLineCount: Int
|
||||||
|
public var sourceLineCount: Int
|
||||||
|
public var renderedLineCount: Int
|
||||||
|
public var headingPlanCount: Int
|
||||||
|
public var paragraphPlanCount: Int
|
||||||
|
public var inlineSpanCount: Int
|
||||||
|
public var markdownDelimiterSpanCount: Int
|
||||||
|
public var unsupportedReferenceLinkLikeCount: Int
|
||||||
|
|
||||||
|
public var renderedModeWasScheduled: Bool {
|
||||||
|
renderedLineCount > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EditorBenchmarkProfiler {
|
||||||
|
public static func profileDocument(at url: URL) throws -> EditorBenchmarkResult {
|
||||||
|
var measurements: [EditorBenchmarkMeasurement] = []
|
||||||
|
|
||||||
|
let readResult = try measure {
|
||||||
|
try String(contentsOf: url, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let source = readResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "file_read",
|
||||||
|
category: .documentLoad,
|
||||||
|
durationMilliseconds: readResult.durationMilliseconds,
|
||||||
|
notes: "String(contentsOf:encoding:)"
|
||||||
|
))
|
||||||
|
|
||||||
|
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
|
||||||
|
let midpoint = source.utf16.count / 2
|
||||||
|
let activeLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
|
||||||
|
|
||||||
|
let documentResult = measure {
|
||||||
|
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
|
||||||
|
}
|
||||||
|
let markdownDocument = documentResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "document_model_init",
|
||||||
|
category: .documentLoad,
|
||||||
|
durationMilliseconds: documentResult.durationMilliseconds,
|
||||||
|
notes: "MarkdownDocument construction"
|
||||||
|
))
|
||||||
|
|
||||||
|
let stateResult = measure {
|
||||||
|
EditorState(
|
||||||
|
document: EditorDocument(markdownDocument: markdownDocument),
|
||||||
|
activeLineIndex: activeLineIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var state = stateResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "document_parse_editor_state_init",
|
||||||
|
category: .documentLoad,
|
||||||
|
durationMilliseconds: stateResult.durationMilliseconds,
|
||||||
|
notes: "Builds EditorState.lines for the whole document"
|
||||||
|
))
|
||||||
|
|
||||||
|
let lineGenerationResult = measure {
|
||||||
|
EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
|
||||||
|
}
|
||||||
|
let lines = lineGenerationResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "line_model_generation",
|
||||||
|
category: .editorUpdate,
|
||||||
|
durationMilliseconds: lineGenerationResult.durationMilliseconds,
|
||||||
|
notes: "EditorActiveLineTracker.lines over full source"
|
||||||
|
))
|
||||||
|
|
||||||
|
let renderPlanResult = measure {
|
||||||
|
let renderer = HybridMarkdownLineRenderer()
|
||||||
|
return lines.map(renderer.renderPlan(for:))
|
||||||
|
}
|
||||||
|
let renderPlans = renderPlanResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "render_plan_generation_all_lines",
|
||||||
|
category: .rendering,
|
||||||
|
durationMilliseconds: renderPlanResult.durationMilliseconds,
|
||||||
|
notes: "HybridMarkdownLineRenderer.renderPlan for every line"
|
||||||
|
))
|
||||||
|
|
||||||
|
let renderedModeTrace = renderedTrace(
|
||||||
|
lines: lines,
|
||||||
|
renderPlans: renderPlans,
|
||||||
|
referenceLinkLikeCount: profile.referenceLinkLikeCount
|
||||||
|
)
|
||||||
|
|
||||||
|
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
|
||||||
|
let changedActiveLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint + 1, in: changedSource)
|
||||||
|
|
||||||
|
let activeLineResult = measure {
|
||||||
|
EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "active_line_lookup",
|
||||||
|
category: .editorUpdate,
|
||||||
|
durationMilliseconds: activeLineResult.durationMilliseconds,
|
||||||
|
notes: "Line lookup for midpoint cursor location"
|
||||||
|
))
|
||||||
|
|
||||||
|
let selectionUpdateResult = measure {
|
||||||
|
state.updateSelection(EditorSelection(location: midpoint, length: 0))
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "selection_update",
|
||||||
|
category: .editorUpdate,
|
||||||
|
durationMilliseconds: selectionUpdateResult.durationMilliseconds,
|
||||||
|
notes: "EditorState.updateSelection rebuilds line model"
|
||||||
|
))
|
||||||
|
|
||||||
|
let dirtyClickResult = measure {
|
||||||
|
EditorDirtyLineInvalidator.plan(
|
||||||
|
previousText: source,
|
||||||
|
currentText: source,
|
||||||
|
previousActiveLineIndex: activeLineIndex,
|
||||||
|
currentActiveLineIndex: changedActiveLineIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let dirtyClickPlan = dirtyClickResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "dirty_line_invalidation_click",
|
||||||
|
category: .editorUpdate,
|
||||||
|
durationMilliseconds: dirtyClickResult.durationMilliseconds,
|
||||||
|
notes: "\(dirtyClickPlan.dirtyLineCount) dirty lines"
|
||||||
|
))
|
||||||
|
|
||||||
|
let sourceUpdateResult = measure {
|
||||||
|
var updatedState = state
|
||||||
|
updatedState.updateSource(changedSource)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "typing_state_update",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: sourceUpdateResult.durationMilliseconds,
|
||||||
|
notes: "EditorState.updateSource after one-character insertion"
|
||||||
|
))
|
||||||
|
|
||||||
|
let dirtyTypingResult = measure {
|
||||||
|
EditorDirtyLineInvalidator.plan(
|
||||||
|
previousText: source,
|
||||||
|
currentText: changedSource,
|
||||||
|
previousActiveLineIndex: activeLineIndex,
|
||||||
|
currentActiveLineIndex: changedActiveLineIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let dirtyTypingPlan = dirtyTypingResult.value
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "dirty_line_invalidation_typing",
|
||||||
|
category: .editorUpdate,
|
||||||
|
durationMilliseconds: dirtyTypingResult.durationMilliseconds,
|
||||||
|
notes: "\(dirtyTypingPlan.dirtyLineCount) dirty lines"
|
||||||
|
))
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
measurements.append(contentsOf: profileTextKit(
|
||||||
|
source: source,
|
||||||
|
changedSource: changedSource,
|
||||||
|
activeLineIndex: activeLineIndex,
|
||||||
|
changedActiveLineIndex: changedActiveLineIndex,
|
||||||
|
dirtyClickPlan: dirtyClickPlan,
|
||||||
|
dirtyTypingPlan: dirtyTypingPlan
|
||||||
|
))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return EditorBenchmarkResult(
|
||||||
|
document: profile,
|
||||||
|
measurements: measurements,
|
||||||
|
renderedModeTrace: renderedModeTrace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func documentProfile(fileName: String, source: String) -> EditorBenchmarkDocumentProfile {
|
||||||
|
let lines = source.components(separatedBy: "\n")
|
||||||
|
let lineLengths = lines.map { $0.utf16.count }
|
||||||
|
let lineCount = lines.count
|
||||||
|
let maxLineLength = lineLengths.max() ?? 0
|
||||||
|
let totalLineLength = lineLengths.reduce(0, +)
|
||||||
|
|
||||||
|
return EditorBenchmarkDocumentProfile(
|
||||||
|
fileName: fileName,
|
||||||
|
byteCount: source.lengthOfBytes(using: .utf8),
|
||||||
|
characterCount: source.count,
|
||||||
|
utf16Count: source.utf16.count,
|
||||||
|
lineCount: lineCount,
|
||||||
|
headingCount: countMatches("(?m)^#{1,6}\\s", in: source),
|
||||||
|
unorderedListItemCount: countMatches("(?m)^[-*+]\\s", in: source),
|
||||||
|
orderedListItemCount: countMatches("(?m)^\\d+\\.\\s", in: source),
|
||||||
|
blockquoteCount: countMatches("(?m)^>\\s", in: source),
|
||||||
|
fencedCodeFenceCount: countMatches("(?m)^```", in: source),
|
||||||
|
inlineCodeLineCount: countMatchingLines("`[^`\\n]+`", in: lines),
|
||||||
|
boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lines),
|
||||||
|
italicLineCount: countMatchingLines("(?<!\\*)\\*[^*\\n]+\\*(?!\\*)", in: lines),
|
||||||
|
inlineLinkCount: countMatches("(?<!!)\\[[^\\]]+\\]\\([^\\)]+\\)", in: source),
|
||||||
|
referenceLinkLikeCount: countMatches("(?<!!)\\[[A-Za-z0-9_-]+\\]", in: source),
|
||||||
|
imageCount: countMatches("!\\[", in: source),
|
||||||
|
maxLineUTF16Length: maxLineLength,
|
||||||
|
averageLineUTF16Length: lineCount == 0 ? 0 : Double(totalLineLength) / Double(lineCount)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func renderedTrace(
|
||||||
|
lines: [EditorLine],
|
||||||
|
renderPlans: [HybridMarkdownLineRenderPlan],
|
||||||
|
referenceLinkLikeCount: Int
|
||||||
|
) -> EditorRenderedModeTrace {
|
||||||
|
let headingCount = renderPlans.filter {
|
||||||
|
if case .heading = $0.kind {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}.count
|
||||||
|
let paragraphCount = renderPlans.count - headingCount
|
||||||
|
let spans = renderPlans.flatMap(\.spans)
|
||||||
|
|
||||||
|
return EditorRenderedModeTrace(
|
||||||
|
totalLineCount: lines.count,
|
||||||
|
sourceLineCount: lines.filter { $0.mode == .source }.count,
|
||||||
|
renderedLineCount: lines.filter { $0.mode == .rendered }.count,
|
||||||
|
headingPlanCount: headingCount,
|
||||||
|
paragraphPlanCount: paragraphCount,
|
||||||
|
inlineSpanCount: spans.count,
|
||||||
|
markdownDelimiterSpanCount: spans.filter { $0.kind == .markdownDelimiter }.count,
|
||||||
|
unsupportedReferenceLinkLikeCount: referenceLinkLikeCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sourceByInsertingProbeText(in source: String, at utf16Location: Int) -> String {
|
||||||
|
let nsSource = source as NSString
|
||||||
|
let location = max(0, min(utf16Location, nsSource.length))
|
||||||
|
return nsSource.replacingCharacters(in: NSRange(location: location, length: 0), with: "x")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func countMatches(_ pattern: String, in source: String) -> Int {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
|
||||||
|
return regex.numberOfMatches(in: source, range: NSRange(location: 0, length: source.utf16.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func countMatchingLines(_ pattern: String, in lines: [String]) -> Int {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return 0 }
|
||||||
|
return lines.reduce(0) { count, line in
|
||||||
|
let range = NSRange(location: 0, length: line.utf16.count)
|
||||||
|
return count + (regex.firstMatch(in: line, range: range) == nil ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func measure<T>(_ operation: () throws -> T) rethrows -> (value: T, durationMilliseconds: Double) {
|
||||||
|
let start = DispatchTime.now().uptimeNanoseconds
|
||||||
|
let value = try operation()
|
||||||
|
let end = DispatchTime.now().uptimeNanoseconds
|
||||||
|
return (value, Double(end - start) / 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private static func profileTextKit(
|
||||||
|
source: String,
|
||||||
|
changedSource: String,
|
||||||
|
activeLineIndex: Int,
|
||||||
|
changedActiveLineIndex: Int,
|
||||||
|
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
|
||||||
|
dirtyTypingPlan: EditorDirtyLineInvalidationPlan
|
||||||
|
) -> [EditorBenchmarkMeasurement] {
|
||||||
|
var measurements: [EditorBenchmarkMeasurement] = []
|
||||||
|
let textStorage = NSTextStorage()
|
||||||
|
let layoutManager = NSLayoutManager()
|
||||||
|
let textContainer = NSTextContainer(size: NSSize(width: 760, height: CGFloat.greatestFiniteMagnitude))
|
||||||
|
textContainer.widthTracksTextView = false
|
||||||
|
textContainer.heightTracksTextView = false
|
||||||
|
layoutManager.addTextContainer(textContainer)
|
||||||
|
textStorage.addLayoutManager(layoutManager)
|
||||||
|
|
||||||
|
let storageUpdateResult = measure {
|
||||||
|
textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: source)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "text_storage_initial_update",
|
||||||
|
category: .layout,
|
||||||
|
durationMilliseconds: storageUpdateResult.durationMilliseconds,
|
||||||
|
notes: "NSTextStorage.replaceCharacters full source"
|
||||||
|
))
|
||||||
|
|
||||||
|
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
|
||||||
|
previousText: nil,
|
||||||
|
currentText: source,
|
||||||
|
previousActiveLineIndex: nil,
|
||||||
|
currentActiveLineIndex: activeLineIndex
|
||||||
|
)
|
||||||
|
let attributedStringResult = measure {
|
||||||
|
MarkdownTextStyler.apply(
|
||||||
|
to: textStorage,
|
||||||
|
invalidationPlan: fullRenderPlan,
|
||||||
|
activeLineIndex: activeLineIndex,
|
||||||
|
backgroundColor: .textBackgroundColor,
|
||||||
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||||
|
textColor: .labelColor,
|
||||||
|
secondaryTextColor: .secondaryLabelColor,
|
||||||
|
accentColor: .controlAccentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "attributed_string_generation_initial",
|
||||||
|
category: .rendering,
|
||||||
|
durationMilliseconds: attributedStringResult.durationMilliseconds,
|
||||||
|
notes: "\(attributedStringResult.value.styledLineCount) styled lines"
|
||||||
|
))
|
||||||
|
|
||||||
|
let layoutInvalidationResult = measure {
|
||||||
|
var actualRange = NSRange(location: 0, length: 0)
|
||||||
|
layoutManager.invalidateLayout(
|
||||||
|
forCharacterRange: NSRange(location: 0, length: textStorage.length),
|
||||||
|
actualCharacterRange: &actualRange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "layout_invalidation_full_document",
|
||||||
|
category: .layout,
|
||||||
|
durationMilliseconds: layoutInvalidationResult.durationMilliseconds,
|
||||||
|
notes: "NSLayoutManager.invalidateLayout full character range"
|
||||||
|
))
|
||||||
|
|
||||||
|
let glyphGenerationResult = measure {
|
||||||
|
layoutManager.ensureGlyphs(forCharacterRange: NSRange(location: 0, length: textStorage.length))
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "glyph_generation_full_document",
|
||||||
|
category: .layout,
|
||||||
|
durationMilliseconds: glyphGenerationResult.durationMilliseconds,
|
||||||
|
notes: "NSLayoutManager.ensureGlyphs full character range"
|
||||||
|
))
|
||||||
|
|
||||||
|
let layoutGenerationResult = measure {
|
||||||
|
layoutManager.ensureLayout(for: textContainer)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "layout_generation_full_document",
|
||||||
|
category: .layout,
|
||||||
|
durationMilliseconds: layoutGenerationResult.durationMilliseconds,
|
||||||
|
notes: "NSLayoutManager.ensureLayout for text container"
|
||||||
|
))
|
||||||
|
|
||||||
|
let firstViewportResult = measure {
|
||||||
|
layoutManager.glyphRange(
|
||||||
|
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
|
||||||
|
in: textContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "first_viewport_glyph_range",
|
||||||
|
category: .layout,
|
||||||
|
durationMilliseconds: firstViewportResult.durationMilliseconds,
|
||||||
|
notes: "Visible glyph range at document top"
|
||||||
|
))
|
||||||
|
|
||||||
|
let clickRenderResult = measure {
|
||||||
|
MarkdownTextStyler.apply(
|
||||||
|
to: textStorage,
|
||||||
|
invalidationPlan: dirtyClickPlan,
|
||||||
|
activeLineIndex: changedActiveLineIndex,
|
||||||
|
backgroundColor: .textBackgroundColor,
|
||||||
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||||
|
textColor: .labelColor,
|
||||||
|
secondaryTextColor: .secondaryLabelColor,
|
||||||
|
accentColor: .controlAccentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "render_update_click_dirty",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: clickRenderResult.durationMilliseconds,
|
||||||
|
notes: "\(clickRenderResult.value.styledLineCount) styled lines"
|
||||||
|
))
|
||||||
|
|
||||||
|
let typingStorageResult = measure {
|
||||||
|
let midpoint = textStorage.length / 2
|
||||||
|
textStorage.replaceCharacters(in: NSRange(location: midpoint, length: 0), with: "x")
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "text_storage_typing_insert",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: typingStorageResult.durationMilliseconds,
|
||||||
|
notes: "NSTextStorage single-character insertion"
|
||||||
|
))
|
||||||
|
|
||||||
|
let typingRenderResult = measure {
|
||||||
|
MarkdownTextStyler.apply(
|
||||||
|
to: textStorage,
|
||||||
|
invalidationPlan: dirtyTypingPlan,
|
||||||
|
activeLineIndex: changedActiveLineIndex,
|
||||||
|
backgroundColor: .textBackgroundColor,
|
||||||
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||||
|
textColor: .labelColor,
|
||||||
|
secondaryTextColor: .secondaryLabelColor,
|
||||||
|
accentColor: .controlAccentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "render_update_typing_dirty",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: typingRenderResult.durationMilliseconds,
|
||||||
|
notes: "\(typingRenderResult.value.styledLineCount) styled lines"
|
||||||
|
))
|
||||||
|
|
||||||
|
let layoutAfterTypingResult = measure {
|
||||||
|
layoutManager.ensureLayout(forCharacterRange: NSRange(location: textStorage.length / 2, length: 1))
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "layout_after_typing",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: layoutAfterTypingResult.durationMilliseconds,
|
||||||
|
notes: "NSLayoutManager.ensureLayout for inserted character"
|
||||||
|
))
|
||||||
|
|
||||||
|
let scrollTopResult = measure {
|
||||||
|
layoutManager.glyphRange(
|
||||||
|
forBoundingRect: NSRect(x: 0, y: 0, width: 760, height: 900),
|
||||||
|
in: textContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "scroll_latency_top_viewport",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: scrollTopResult.durationMilliseconds,
|
||||||
|
notes: "glyphRange for top viewport"
|
||||||
|
))
|
||||||
|
|
||||||
|
let scrollMiddleResult = measure {
|
||||||
|
layoutManager.glyphRange(
|
||||||
|
forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900),
|
||||||
|
in: textContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "scroll_latency_middle_viewport",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: scrollMiddleResult.durationMilliseconds,
|
||||||
|
notes: "glyphRange for estimated middle viewport"
|
||||||
|
))
|
||||||
|
|
||||||
|
let scrollDeepResult = measure {
|
||||||
|
layoutManager.glyphRange(
|
||||||
|
forBoundingRect: NSRect(x: 0, y: 500_000, width: 760, height: 900),
|
||||||
|
in: textContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
|
name: "scroll_latency_deep_viewport",
|
||||||
|
category: .interaction,
|
||||||
|
durationMilliseconds: scrollDeepResult.durationMilliseconds,
|
||||||
|
notes: "glyphRange for estimated deep viewport"
|
||||||
|
))
|
||||||
|
|
||||||
|
_ = changedSource
|
||||||
|
return measurements
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
@ -552,14 +552,14 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private struct MarkdownTextStylingResult {
|
struct MarkdownTextStylingResult {
|
||||||
var totalLineCount: Int
|
var totalLineCount: Int
|
||||||
var styledLineCount: Int
|
var styledLineCount: Int
|
||||||
|
|
||||||
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
|
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum MarkdownTextStyler {
|
enum MarkdownTextStyler {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
typealias PlatformColor = NSColor
|
typealias PlatformColor = NSColor
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
|
|
|
||||||
95
Sources/SaplingEditorBenchmark/main.swift
Normal file
95
Sources/SaplingEditorBenchmark/main.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import Foundation
|
||||||
|
import SaplingCore
|
||||||
|
import SaplingEditor
|
||||||
|
|
||||||
|
struct BenchmarkScenario {
|
||||||
|
var name: String
|
||||||
|
var url: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||||
|
let repositoryRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let scenarios = try benchmarkScenarios(arguments: arguments, repositoryRoot: repositoryRoot)
|
||||||
|
print("# Sapling Editor Benchmark")
|
||||||
|
print("")
|
||||||
|
print("Generated: \(ISO8601DateFormatter().string(from: Date()))")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
for scenario in scenarios {
|
||||||
|
let result = try EditorBenchmarkProfiler.profileDocument(at: scenario.url)
|
||||||
|
printScenario(name: scenario.name, result: result)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fputs("SaplingEditorBenchmark failed: \(error)\n", stderr)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkScenarios(arguments: [String], repositoryRoot: URL) throws -> [BenchmarkScenario] {
|
||||||
|
if !arguments.isEmpty {
|
||||||
|
return arguments.map { path in
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
return BenchmarkScenario(name: url.deletingPathExtension().lastPathComponent, url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try [
|
||||||
|
sampleScenario(),
|
||||||
|
BenchmarkScenario(
|
||||||
|
name: "hybrid-large-2100",
|
||||||
|
url: repositoryRoot.appendingPathComponent("Docs/EditorPrototypes/hybrid-large-2100.md")
|
||||||
|
),
|
||||||
|
BenchmarkScenario(
|
||||||
|
name: "5mb",
|
||||||
|
url: repositoryRoot.appendingPathComponent("Docs/Benchmarks/5mb.md")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleScenario() throws -> BenchmarkScenario {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("SaplingEditorBenchmark-sample-document.md")
|
||||||
|
try SaplingSampleData.document.content.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return BenchmarkScenario(name: "sample-document", url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printScenario(name: String, result: EditorBenchmarkResult) {
|
||||||
|
print("## \(name)")
|
||||||
|
print("")
|
||||||
|
print("| Property | Value |")
|
||||||
|
print("| --- | ---: |")
|
||||||
|
print("| File | `\(result.document.fileName)` |")
|
||||||
|
print("| Bytes | \(result.document.byteCount) |")
|
||||||
|
print("| Characters | \(result.document.characterCount) |")
|
||||||
|
print("| UTF-16 units | \(result.document.utf16Count) |")
|
||||||
|
print("| Lines | \(result.document.lineCount) |")
|
||||||
|
print("| Max line UTF-16 length | \(result.document.maxLineUTF16Length) |")
|
||||||
|
print("| Average line UTF-16 length | \(format(result.document.averageLineUTF16Length)) |")
|
||||||
|
print("")
|
||||||
|
print("Markdown structure: \(result.document.markdownStructureSummary)")
|
||||||
|
print("")
|
||||||
|
print("Rendered mode trace: \(result.renderedModeTrace.renderedLineCount) rendered lines, "
|
||||||
|
+ "\(result.renderedModeTrace.sourceLineCount) source lines, "
|
||||||
|
+ "\(result.renderedModeTrace.headingPlanCount) heading plans, "
|
||||||
|
+ "\(result.renderedModeTrace.paragraphPlanCount) paragraph plans, "
|
||||||
|
+ "\(result.renderedModeTrace.inlineSpanCount) inline spans, "
|
||||||
|
+ "\(result.renderedModeTrace.unsupportedReferenceLinkLikeCount) reference-style markers.")
|
||||||
|
print("")
|
||||||
|
print("| Rank | Operation | Category | Time | Percent | Notes |")
|
||||||
|
print("| ---: | --- | --- | ---: | ---: | --- |")
|
||||||
|
for (index, measurement) in result.hottestMeasurements.prefix(20).enumerated() {
|
||||||
|
let percentage = result.measuredTotalMilliseconds == 0
|
||||||
|
? 0
|
||||||
|
: measurement.durationMilliseconds / result.measuredTotalMilliseconds * 100
|
||||||
|
print("| \(index + 1) | `\(measurement.name)` | \(measurement.category.rawValue) | "
|
||||||
|
+ "\(format(measurement.durationMilliseconds)) ms | \(format(percentage))% | \(measurement.notes) |")
|
||||||
|
}
|
||||||
|
print("")
|
||||||
|
print("Measured total: \(format(result.measuredTotalMilliseconds)) ms")
|
||||||
|
print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func format(_ value: Double) -> String {
|
||||||
|
String(format: "%.3f", value)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import SaplingEditor
|
||||||
|
|
||||||
|
final class EditorPerformanceProfilingTests: XCTestCase {
|
||||||
|
func testDocumentProfileRecordsMarkdownStructure() {
|
||||||
|
let source = """
|
||||||
|
# Title
|
||||||
|
|
||||||
|
- Item
|
||||||
|
1. Step
|
||||||
|
> Quote
|
||||||
|
Text with **bold**, *italic*, `code`, [ref], and .
|
||||||
|
```
|
||||||
|
code
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
let profile = EditorBenchmarkProfiler.documentProfile(fileName: "Sample.md", source: source)
|
||||||
|
|
||||||
|
XCTAssertEqual(profile.fileName, "Sample.md")
|
||||||
|
XCTAssertEqual(profile.headingCount, 1)
|
||||||
|
XCTAssertEqual(profile.unorderedListItemCount, 1)
|
||||||
|
XCTAssertEqual(profile.orderedListItemCount, 1)
|
||||||
|
XCTAssertEqual(profile.blockquoteCount, 1)
|
||||||
|
XCTAssertEqual(profile.fencedCodeFenceCount, 2)
|
||||||
|
XCTAssertEqual(profile.inlineCodeLineCount, 1)
|
||||||
|
XCTAssertEqual(profile.boldLineCount, 1)
|
||||||
|
XCTAssertEqual(profile.italicLineCount, 1)
|
||||||
|
XCTAssertEqual(profile.inlineLinkCount, 0)
|
||||||
|
XCTAssertEqual(profile.referenceLinkLikeCount, 1)
|
||||||
|
XCTAssertEqual(profile.imageCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProfilerProducesBenchmarkMeasurements() throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let url = directory.appendingPathComponent("Benchmark.md")
|
||||||
|
try "# Heading\n\nText with **bold** and `code`.\n".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let result = try EditorBenchmarkProfiler.profileDocument(at: url)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.document.lineCount, 4)
|
||||||
|
XCTAssertTrue(result.renderedModeTrace.renderedModeWasScheduled)
|
||||||
|
XCTAssertTrue(result.measurements.contains { $0.name == "file_read" })
|
||||||
|
XCTAssertTrue(result.measurements.contains { $0.name == "document_parse_editor_state_init" })
|
||||||
|
XCTAssertTrue(result.measurements.contains { $0.name == "render_plan_generation_all_lines" })
|
||||||
|
XCTAssertTrue(result.measurements.contains { $0.name == "dirty_line_invalidation_typing" })
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue