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: [
|
||||
.executable(name: "SaplingApp", targets: ["SaplingApp"]),
|
||||
.executable(name: "SaplingEditorBenchmark", targets: ["SaplingEditorBenchmark"]),
|
||||
.library(name: "SaplingCore", targets: ["SaplingCore"]),
|
||||
.library(name: "SaplingWorkspace", targets: ["SaplingWorkspace"]),
|
||||
.library(name: "SaplingGit", targets: ["SaplingGit"]),
|
||||
|
|
@ -33,6 +34,13 @@ let package = Package(
|
|||
"SaplingUI"
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "SaplingEditorBenchmark",
|
||||
dependencies: [
|
||||
"SaplingCore",
|
||||
"SaplingEditor"
|
||||
]
|
||||
),
|
||||
.target(name: "SaplingCore"),
|
||||
.target(
|
||||
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
|
||||
|
||||
private struct MarkdownTextStylingResult {
|
||||
struct MarkdownTextStylingResult {
|
||||
var totalLineCount: Int
|
||||
var styledLineCount: Int
|
||||
|
||||
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
|
||||
}
|
||||
|
||||
private enum MarkdownTextStyler {
|
||||
enum MarkdownTextStyler {
|
||||
#if os(macOS)
|
||||
typealias PlatformColor = NSColor
|
||||
#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