Sapling/Sources/SaplingEditor/EditorPerformanceProfiling.swift

654 lines
26 KiB
Swift
Raw Permalink Normal View History

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 lineIndex = DocumentLineIndex(source: source)
let activeLineIndex = lineIndex.lineIndex(containing: midpoint)
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 typingEdit = DocumentLineIndexEdit(
range: NSRange(location: midpoint, length: 0),
replacement: "x"
)
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
var changedLineIndex = lineIndex
changedLineIndex.replace(typingEdit, updatedSource: changedSource)
let changedActiveLineIndex = changedLineIndex.lineIndex(containing: midpoint + 1)
let activeLineResult = measure {
lineIndex.lineIndex(containing: midpoint)
}
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,
currentLineIndex: lineIndex,
edit: nil,
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,
edit: typingEdit,
selection: EditorSelection(location: midpoint + typingEdit.replacementUTF16Length, length: 0)
)
}
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,
currentLineIndex: changedLineIndex,
edit: typingEdit,
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,
lineIndex: lineIndex,
changedLineIndex: changedLineIndex,
typingEdit: typingEdit,
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 lineIndex = DocumentLineIndex(source: source)
let nsSource = source as NSString
let lineSources = lineIndex.boundaries.map { nsSource.substring(with: $0.contentRange) }
let lineLengths = lineIndex.boundaries.map(\.contentRange.length)
let lineCount = lineIndex.boundaries.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: lineSources),
boldLineCount: countMatchingLines("\\*\\*[^*\\n]+\\*\\*", in: lineSources),
italicLineCount: countMatchingLines("(?<!\\*)\\*[^*\\n]+\\*(?!\\*)", in: lineSources),
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,
lineIndex: DocumentLineIndex,
changedLineIndex: DocumentLineIndex,
typingEdit: DocumentLineIndexEdit,
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,
currentLineIndex: lineIndex,
edit: nil,
previousActiveLineIndex: nil,
currentActiveLineIndex: activeLineIndex
)
let attributedStringResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
lineIndex: lineIndex,
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 coldViewportStack = makeTextKitStack(attributedString: textStorage)
let coldViewportResult = measure {
coldViewportStack.layoutManager.glyphRange(
forBoundingRect: NSRect(x: 0, y: 50_000, width: 760, height: 900),
in: coldViewportStack.textContainer
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "cold_viewport_glyph_range_middle",
category: .layout,
durationMilliseconds: coldViewportResult.durationMilliseconds,
notes: "Visible glyph range before explicit full-document layout"
))
let coldLineFragmentStack = makeTextKitStack(attributedString: textStorage)
let coldLineFragmentResult = measure {
lineFragmentRect(atCharacterLocation: coldLineFragmentStack.textStorage.length / 2, in: coldLineFragmentStack)
}
measurements.append(EditorBenchmarkMeasurement(
name: "cold_line_fragment_calculation_midpoint",
category: .layout,
durationMilliseconds: coldLineFragmentResult.durationMilliseconds,
notes: "lineFragmentRect near midpoint before explicit full-document layout"
))
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 cachedLineFragmentResult = measure {
lineFragmentRect(atCharacterLocation: textStorage.length / 2, in: (
textStorage: textStorage,
layoutManager: layoutManager,
textContainer: textContainer
))
}
measurements.append(EditorBenchmarkMeasurement(
name: "cached_line_fragment_calculation_midpoint",
category: .layout,
durationMilliseconds: cachedLineFragmentResult.durationMilliseconds,
notes: "lineFragmentRect near midpoint after full layout"
))
let textContainerUsedRectResult = measure {
layoutManager.usedRect(for: textContainer)
}
measurements.append(EditorBenchmarkMeasurement(
name: "text_container_used_rect_after_full_layout",
category: .layout,
durationMilliseconds: textContainerUsedRectResult.durationMilliseconds,
notes: "NSTextContainer usedRect after full layout"
))
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 {
if dirtyClickPlan.requiresStyling {
return MarkdownTextStyler.apply(
to: textStorage,
lineIndex: lineIndex,
invalidationPlan: dirtyClickPlan,
activeLineIndex: changedActiveLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
}
return MarkdownTextStylingResult.empty
}
measurements.append(EditorBenchmarkMeasurement(
name: "render_update_click_dirty",
category: .interaction,
durationMilliseconds: clickRenderResult.durationMilliseconds,
notes: dirtyClickPlan.requiresStyling
? "\(clickRenderResult.value.styledLineCount) styled lines"
: "skipped; dirty plan did not require styling"
))
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,
lineIndex: changedLineIndex,
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
_ = typingEdit
return measurements
}
private static func makeTextKitStack(attributedString: NSAttributedString) -> (
textStorage: NSTextStorage,
layoutManager: NSLayoutManager,
textContainer: NSTextContainer
) {
let textStorage = NSTextStorage(attributedString: attributedString)
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)
return (textStorage, layoutManager, textContainer)
}
@discardableResult
private static func lineFragmentRect(
atCharacterLocation characterLocation: Int,
in stack: (
textStorage: NSTextStorage,
layoutManager: NSLayoutManager,
textContainer: NSTextContainer
)
) -> NSRect {
guard stack.textStorage.length > 0 else { return .zero }
let characterLocation = max(0, min(characterLocation, stack.textStorage.length - 1))
let glyphIndex = stack.layoutManager.glyphIndexForCharacter(at: characterLocation)
var effectiveRange = NSRange(location: 0, length: 0)
return stack.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange)
}
#endif
}