# 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:

```sh
swift run -c release SaplingEditorBenchmark
```

Single-file command:

```sh
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:

```mermaid
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:

```mermaid
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:

```mermaid
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:

```text
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.

```mermaid
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

```sh
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
```
