Sapling/Docs/performance-report-5mb.md

17 KiB

5 MB Editor Performance Report

Date: 2026-05-30

Milestone: 2.6 Large Document Profiling

Status: Investigation complete; no editor optimization or redesign was performed.

Executive Summary

The 5 MB document becomes unusable primarily because Sapling's current line tracker does not split CRLF line endings. The benchmark file has 51,481 CRLF pairs. Swift treats "\r\n" as one newline grapheme cluster, and EditorActiveLineTracker.lines(from:) searches the String for Character("\n"). The result is one giant EditorLine containing the entire 5.5M UTF-16-unit document.

Evidence:

  • Asset profile: 51,482 physical lines, 51,481 CR characters, 51,481 LF characters.
  • Swift check: "a\r\nb".firstIndex(of: "\n") == nil; "a\r\nb".split(separator: "\n").count == 1.
  • Benchmark rendered-mode trace for Docs/Benchmarks/5mb.md: 0 rendered lines, 1 source line.
  • The initial styling pass reports 1 styled line, not 51,482 styled lines.

This explains the visible source-only behavior: the editor believes the whole document is the active line. Dirty-line invalidation is technically working, but the dirty line is the entire file.

Benchmark Asset

The benchmark file is now stored at:

Docs/Benchmarks/5mb.md

Recorded characteristics:

Property Value
File size 5,526,168 bytes
Characters 5,474,683
UTF-16 units 5,526,164
Physical lines 51,482
CR characters 51,481
LF characters 51,481
Max line UTF-16 length 659
Average line UTF-16 length 106.342

Markdown structure:

Construct Count
ATX headings 2
Unordered list items 2
Ordered list items 0
Blockquotes 0
Fenced code fences 0
Lines with inline code 0
Lines with bold markers 0
Lines with italic markers 0
Inline links 0
Reference-style link markers 51,156
Images 0

The document mostly consists of repeated reference-style link text. Milestone 2 rendering does not promote reference-style links, so even after correct line splitting most lines would remain visually close to source text unless future rendering adds support for that construct.

Benchmark Harness

Reusable benchmark command:

swift run -c release SaplingEditorBenchmark

Single-file command:

swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md

The harness measures:

  • file read time
  • document model creation
  • editor state parse and line-model creation
  • render-plan generation
  • initial attributed-string generation
  • active-line lookup
  • selection update
  • dirty-line invalidation for click and typing paths
  • dirty render update for typing
  • NSTextStorage updates
  • NSLayoutManager glyph and layout generation
  • viewport glyph-range lookup as a scroll proxy

Percentages below are percentages of measured benchmark stage time, not Instruments Time Profiler samples.

Scenario Summary

Release benchmark run on May 30, 2026:

Scenario Lines Rendered trace Measured total
Sample document 54 53 rendered, 1 source 7.722 ms
2,100-line prototype 2,101 2,100 rendered, 1 source 348.580 ms
5 MB benchmark 51,482 physical, 1 editor line 0 rendered, 1 source 4,376.271 ms

Top 20 Operations

5 MB release benchmark:

Rank Operation Category Time Percent Evidence
1 attributed_string_generation_initial rendering 903.494 ms 20.645% 1 styled line
2 render_update_typing_dirty interaction 887.213 ms 20.273% 1 styled line
3 layout_generation_full_document layout 766.331 ms 17.511% NSLayoutManager.ensureLayout
4 dirty_line_invalidation_typing editor update 566.852 ms 12.953% 1 dirty line
5 layout_after_typing interaction 490.160 ms 11.200% layout for inserted character
6 glyph_generation_full_document layout 157.546 ms 3.600% full character range
7 typing_state_update interaction 136.721 ms 3.124% EditorState.updateSource
8 selection_update editor update 136.422 ms 3.117% EditorState.updateSelection
9 active_line_lookup editor update 73.509 ms 1.680% midpoint cursor lookup
10 line_model_generation editor update 63.505 ms 1.451% EditorActiveLineTracker.lines
11 document_parse_editor_state_init document load 63.444 ms 1.450% initial EditorState
12 dirty_line_invalidation_click editor update 63.043 ms 1.441% 0 dirty lines
13 render_plan_generation_all_lines rendering 51.323 ms 1.173% one render plan
14 text_storage_initial_update layout 10.957 ms 0.250% full source replace
15 file_read document load 4.954 ms 0.113% disk read
16 text_storage_typing_insert interaction 0.711 ms 0.016% one-character insertion
17 document_model_init document load 0.044 ms 0.001% MarkdownDocument
18 scroll_latency_middle_viewport interaction 0.013 ms 0.000% cached glyph-range lookup
19 scroll_latency_deep_viewport interaction 0.012 ms 0.000% cached glyph-range lookup
20 scroll_latency_top_viewport interaction 0.011 ms 0.000% cached glyph-range lookup

What Consumes the Most CPU and Time?

Measured stage totals indicate the dominant costs are:

Area Representative operations Time
Rendering/styling one giant line initial attributed styling, dirty typing styling 1,790.707 ms
TextKit layout full layout, glyph generation, layout after typing 1,414.037 ms
Whole-document editor scans dirty invalidation, selection/source updates, active-line lookup, line generation 1,103.496 ms
File/model load file read, model init, state init 68.442 ms

The largest single cost is initial attributed styling. However, the more important finding is that both initial styling and dirty typing styling operate on one giant line because the CRLF document is not split into editor lines.

Interaction Paths

Clicking

Observed measured path:

sequenceDiagram
    participant User
    participant TextView as NSTextView
    participant VM as HybridMarkdownEditorViewModel
    participant State as EditorState
    participant Dirty as EditorDirtyLineInvalidator

    User->>TextView: Click
    TextView->>VM: updateSelection
    VM->>State: updateSelection
    State->>State: lineIndex + lines over full source
    TextView->>Dirty: plan same source/active line
    Dirty-->>TextView: 0 dirty lines, render skipped

Measured operations for the 5 MB file:

  • selection_update: 136.422 ms
  • dirty_line_invalidation_click: 63.043 ms
  • render update: skipped because no dirty lines were scheduled

Clicking is not dominated by Markdown rendering in this benchmark. It is dominated by whole-document source scans and line-model rebuilding.

Typing

Observed measured path:

sequenceDiagram
    participant User
    participant TextStorage as NSTextStorage
    participant VM as ViewModel
    participant State as EditorState
    participant Dirty as Dirty invalidator
    participant Styler as MarkdownTextStyler
    participant Layout as NSLayoutManager

    User->>TextStorage: Insert one character
    TextStorage->>VM: updateSource
    VM->>State: updateSource
    State->>State: lineIndex + lines over full source
    VM->>Dirty: plan previous/current source
    Dirty->>Dirty: prefix/suffix diff over full source
    Dirty->>Styler: 1 dirty line
    Styler->>Styler: restyle the one giant line
    Styler->>Layout: invalidate layout
    Layout->>Layout: layout inserted character

Measured operations for the 5 MB file:

  • typing_state_update: 136.721 ms
  • dirty_line_invalidation_typing: 566.852 ms
  • render_update_typing_dirty: 887.213 ms
  • text_storage_typing_insert: 0.711 ms
  • layout_after_typing: 490.160 ms

Typing is slow because dirty invalidation diffing, dirty rendering, and layout all act on the whole source or on one giant visual/logical line.

Scrolling

The harness measured cached viewport glyph-range lookup after full layout:

  • top viewport: 0.011 ms
  • middle viewport: 0.013 ms
  • deep viewport: 0.012 ms

This does not reproduce the user's slow visible scrolling by itself. The measured expensive TextKit operations are:

  • layout_generation_full_document: 766.331 ms
  • glyph_generation_full_document: 157.546 ms
  • layout_after_typing: 490.160 ms

Evidence supports this conclusion: cached glyph-range lookup is cheap after layout, but layout generation for the one giant wrapped line is expensive. A visible AppKit scroll-wheel trace is still needed to prove whether slow scrolling comes from repeated layout invalidation, drawing, or accessibility/selection work during live scrolling.

Rendering Investigation

Rendered mode appears absent because the current line tracker emits one editor line for the entire CRLF document.

Actual execution path:

flowchart TD
    File["5mb.md with CRLF endings"] --> SwiftString["Swift String"]
    SwiftString --> Tracker["EditorActiveLineTracker.lines"]
    Tracker --> Search["firstIndex(of: Character newline)"]
    Search --> OneLine["No Character(\\n) match inside CRLF grapheme clusters"]
    OneLine --> State["EditorState.lines = 1"]
    State --> Active["activeLineIndex = 0"]
    Active --> Modes["1 source line, 0 rendered lines"]
    Modes --> Styler["MarkdownTextStyler styles active source line"]

Evidence:

CR=51481 LF=51481
"a\r\nb".firstIndex(of: "\n") == nil
"a\r\nb".split(separator: "\n").count == 1
(NSString("a\r\nb")).components(separatedBy: "\n").count == 2

There is no evidence of an explicit safety fallback, disabled rendering threshold, performance guard, or render-plan failure. The rendered mode is absent because the editor's line model contains no inactive lines.

Scalability Audit

Operation Trigger Frequency Complexity Evidence / justification
String(contentsOf:) document open once O(n) bytes 4.954 ms for 5.5 MB
EditorState.init document open once O(n) builds EditorState.lines; 63.444 ms
EditorActiveLineTracker.lines open, source update, selection update, dirty plan, styler repeated O(n) scans source to create ranges; 63.505 ms standalone
EditorActiveLineTracker.lineIndex cursor/selection/source updates repeated O(n) scans split source; 73.509 ms midpoint lookup
EditorState.updateSelection click/cursor movement every selection change O(n) rebuilds lines; 136.422 ms
EditorState.updateSource typing every source edit O(n) rebuilds lines; 136.721 ms
Dirty diff prefix/suffix scan source edit every source edit O(n) dirty_line_invalidation_typing 566.852 ms
Dirty line selection source edit/click every dirty plan O(n) today dirty plan first rebuilds current lines
Full render-plan generation benchmark / future full render on demand O(line count + inline regex work) 51.323 ms, but only one editor line under CRLF bug
Initial attributed styling initial render once per view text load O(n + styled line content) 903.494 ms on one giant line
Dirty attributed styling typing/active line change per dirty render O(n + dirty content) today 887.213 ms because styler rebuilds lines and styles one giant line
NSTextStorage.replaceCharacters full text load once O(n) 10.957 ms
NSTextStorage.replaceCharacters insert typing every edit local TextKit edit 0.711 ms
NSLayoutManager.ensureGlyphs full initial layout once / after invalidation O(n) 157.546 ms
NSLayoutManager.ensureLayout full initial layout once / after invalidation O(n layout fragments) 766.331 ms
ensureLayout for inserted char typing every edit TextKit-dependent 490.160 ms because the document is one huge wrapped line
Viewport glyph range scroll proxy after layout per query near O(1) after cached layout 0.011-0.013 ms in harness

No O(n^2) operation was proven by this benchmark. The measured failure is multiple O(n) scans and TextKit layout passes on a 5.5M-character single editor line.

Complexity Analysis

Workflow Current complexity Evidence
Document opening O(n) read + O(n) editor line model + O(n) initial styling + O(n) layout 4.954 ms read, 63.444 ms state init, 903.494 ms styling, 766.331 ms layout
Typing O(n) state update + O(n) dirty diff + O(n) dirty styling today + TextKit layout cost 136.721 ms + 566.852 ms + 887.213 ms + 490.160 ms
Clicking / cursor movement O(n) selection update + O(n) dirty plan 136.422 ms + 63.043 ms
Active-line changes O(n) line lookup + O(n) line model rebuild + dirty render measured through selection and dirty planning
Scrolling after cached layout not proven O(n) by harness viewport glyph-range lookup was near zero after full layout
Scrolling during invalidated layout likely TextKit layout-bound, not isolated by this harness full layout and post-typing layout are expensive; live scroll trace still required

Any operation that calls EditorActiveLineTracker.lines, EditorActiveLineTracker.lineIndex, or EditorDirtyLineInvalidator.plan currently grows with total document size.

Viewport Analysis

Sapling currently operates on the entire document for editor state and rendering inputs. It does not operate on visible lines plus a buffer.

flowchart TD
    Source["Full Markdown source"] --> State["EditorState.lines for full source"]
    Source --> Dirty["Dirty invalidator scans full previous/current source"]
    Source --> Styler["MarkdownTextStyler recomputes full line list"]
    Styler --> TextStorage["NSTextStorage dirty attributes"]
    TextStorage --> Layout["NSLayoutManager layout/glyph cache"]
    Layout --> Viewport["Visible viewport"]
Subsystem Entire document or viewport? Evidence
Active-line update Entire document lineIndex(containing:in:) scans source; 73.509 ms
Selection update Entire document updateSelection rebuilds lines; 136.422 ms
Source update Entire document updateSource rebuilds lines; 136.721 ms
Dirty invalidation Entire document compares previous/current strings and rebuilds current lines; 566.852 ms
Render planning Entire document in full benchmark; dirty line only for actual render plans after valid dirty plan full render-plan generation measured separately
Attributed styling Dirty line set, but line list recomputed from entire document 887.213 ms for one dirty line
TextKit layout TextKit-managed full layout/glyph cache full layout 766.331 ms
Scroll lookup after layout Viewport query against cached layout 0.011-0.013 ms

Bottleneck Assessment

The bottleneck is not one subsystem alone.

Evidence ranks:

  1. CRLF line tracking collapses the document to one giant editor line. This is the root cause of rendered mode being absent and makes dirty-line rendering degenerate.
  2. Rendering/styling is expensive because the one dirty line is the whole document.
  3. TextKit layout is expensive because the document is one giant wrapped line.
  4. Active-line, selection, source update, and dirty invalidation perform whole-document scans.

File I/O is not the bottleneck. file_read was 4.954 ms in the benchmark.

Recommendations For Future Work

These are recommendations from evidence, not implemented changes:

  1. Normalize or explicitly handle CRLF line endings in the editor line tracker before any larger architecture work. This is the highest-leverage fix because it restores real line granularity and rendered mode.
  2. After CRLF handling, rerun this benchmark. Many measured costs should change because dirty lines will stop being the entire document.
  3. Remove full-document line-model rebuilding from selection changes. Cursor movement should not rebuild every EditorLine.
  4. Change dirty invalidation to use native edited ranges instead of prefix/suffix diffing full strings on every edit.
  5. Change MarkdownTextStyler.apply so dirty rendering does not recompute the full line list before styling dirty ranges.
  6. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after CRLF handling.

Reproduction Commands

perl -0ne '$cr = tr/\r//; $lf = tr/\n//; print "CR=$cr LF=$lf\n"' Docs/Benchmarks/5mb.md
swift -e 'import Foundation; let s = "a\r\nb"; print(s.firstIndex(of: "\n") as Any); print(s.split(separator: "\n", omittingEmptySubsequences: false).count); print((s as NSString).components(separatedBy: "\n").count)'
swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md