docs(perf): map editor scalability bottlenecks
This commit is contained in:
parent
26ed4956e1
commit
6426dc494a
3 changed files with 581 additions and 3 deletions
207
Docs/editor-interaction-traces.md
Normal file
207
Docs/editor-interaction-traces.md
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
# Editor Interaction Traces
|
||||||
|
|
||||||
|
Date: 2026-05-30
|
||||||
|
|
||||||
|
Milestone: 2.8 Editor Scalability
|
||||||
|
|
||||||
|
Status: Investigation only. No editor optimization, renderer redesign, or TextKit replacement was performed.
|
||||||
|
|
||||||
|
## Source Measurements
|
||||||
|
|
||||||
|
Measurements come from the release benchmark command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift run -c release SaplingEditorBenchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
The 5 MB benchmark document is `Docs/Benchmarks/5mb.md`.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --- | ---: |
|
||||||
|
| Bytes | 5,526,168 |
|
||||||
|
| Characters | 5,474,683 |
|
||||||
|
| UTF-16 units | 5,526,164 |
|
||||||
|
| Editor lines | 51,482 |
|
||||||
|
| Rendered/source trace | 51,481 rendered, 1 source |
|
||||||
|
|
||||||
|
Performance budgets used in this trace:
|
||||||
|
|
||||||
|
| Interaction | Target |
|
||||||
|
| --- | ---: |
|
||||||
|
| Typing | <16 ms |
|
||||||
|
| Cursor movement | <16 ms |
|
||||||
|
| Selection update | <16 ms |
|
||||||
|
| Scroll update | <16 ms when layout is cached |
|
||||||
|
|
||||||
|
## Document Opening
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant File as "File system"
|
||||||
|
participant VM as "HybridMarkdownEditorViewModel"
|
||||||
|
participant State as "EditorState"
|
||||||
|
participant Lines as "DocumentLineIndex"
|
||||||
|
participant Styler as "MarkdownTextStyler"
|
||||||
|
participant TextKit as "NSTextStorage / NSLayoutManager"
|
||||||
|
|
||||||
|
File->>VM: "String(contentsOf:)"
|
||||||
|
VM->>State: "EditorState(document:)"
|
||||||
|
State->>Lines: "Build all editor lines"
|
||||||
|
Lines-->>State: "51,482 lines"
|
||||||
|
State->>Styler: "Initial full render plan"
|
||||||
|
Styler->>TextKit: "Apply attributes to full storage"
|
||||||
|
TextKit->>TextKit: "Generate glyphs and layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | Time | Complexity | Scaling dependency |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| `file_read` | 2.525 ms | O(document bytes) | Total document |
|
||||||
|
| `document_parse_editor_state_init` | 217.066 ms | O(document UTF-16 units + lines) | Total document |
|
||||||
|
| `line_model_generation` | 205.885 ms | O(document UTF-16 units + lines) | Total document |
|
||||||
|
| `render_plan_generation_all_lines` | 611.781 ms | O(lines + inline parse work) | Total document |
|
||||||
|
| `attributed_string_generation_initial` | 787.685 ms | O(document UTF-16 units + lines) | Total document |
|
||||||
|
| `text_storage_initial_update` | 14.469 ms | O(document UTF-16 units) | Total document |
|
||||||
|
| `glyph_generation_full_document` | 200.528 ms | O(document UTF-16 units) | Total document |
|
||||||
|
| `layout_generation_full_document` | 1,314.100 ms | O(layout fragments) | Total document |
|
||||||
|
|
||||||
|
Opening is document-bound. The file read is not the bottleneck; editor line generation, render planning, attributed styling, and TextKit full layout are.
|
||||||
|
|
||||||
|
## Cursor Movement
|
||||||
|
|
||||||
|
Cursor movement means a collapsed selection change without source text changes.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant TextView as "NSTextView"
|
||||||
|
participant Adapter as "Coordinator"
|
||||||
|
participant Dirty as "EditorDirtyLineInvalidator"
|
||||||
|
participant Styler as "MarkdownTextStyler"
|
||||||
|
participant VM as "ViewModel binding"
|
||||||
|
participant State as "EditorState"
|
||||||
|
|
||||||
|
User->>TextView: "Move insertion point"
|
||||||
|
TextView->>Adapter: "textViewDidChangeSelection"
|
||||||
|
Adapter->>Dirty: "plan(previousText,currentText,activeLine)"
|
||||||
|
Dirty->>Dirty: "Build current lines for full document"
|
||||||
|
Dirty-->>Adapter: "0 dirty lines or active-line dirty lines"
|
||||||
|
Adapter->>Styler: "Apply only if plan requires styling"
|
||||||
|
Adapter->>VM: "parent.selection = newSelection"
|
||||||
|
VM->>State: "updateSelection"
|
||||||
|
State->>State: "lineIndex + full line rebuild"
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured cursor-related stages for the 5 MB document:
|
||||||
|
|
||||||
|
| Stage | Time | Budget result | Complexity | Evidence |
|
||||||
|
| --- | ---: | --- | --- | --- |
|
||||||
|
| `dirty_line_invalidation_click` | 224.418 ms | over budget | O(document) | Builds current lines before deciding 0 dirty lines |
|
||||||
|
| `selection_update` | 398.262 ms | over budget | O(document) | `EditorState.updateSelection` rebuilds line model |
|
||||||
|
| `active_line_lookup` | 191.751 ms | over budget | O(document) | Midpoint line lookup scans line boundaries |
|
||||||
|
| `render_update_click_dirty` | below top-20 threshold | within measured threshold | O(0) when no dirty lines | Render skipped when dirty plan has no styling |
|
||||||
|
|
||||||
|
Cursor movement is document-bound today. The expensive work happens before Markdown rendering.
|
||||||
|
|
||||||
|
## Selection Changes
|
||||||
|
|
||||||
|
Multi-character and multi-line selection changes use the same path as cursor movement, with a different `EditorSelection.length`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Selection["Selection range changes"] --> Adapter["Coordinator.textViewDidChangeSelection"]
|
||||||
|
Adapter --> Dirty["Dirty invalidation over current full text"]
|
||||||
|
Adapter --> Binding["SwiftUI binding update"]
|
||||||
|
Binding --> State["EditorState.updateSelection"]
|
||||||
|
State --> LineIndex["lineIndex(containing:)"]
|
||||||
|
State --> Lines["lines(from:)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| System | Trigger | Time | Complexity | Viewport-bound? |
|
||||||
|
| --- | --- | ---: | --- | --- |
|
||||||
|
| Dirty invalidation | Every observed selection change before binding update | 224.418 ms click proxy | O(document) | No |
|
||||||
|
| Active-line lookup | Every `EditorState.updateSelection` | 191.751 ms standalone | O(document) | No |
|
||||||
|
| Full line rebuild | Every `EditorState.updateSelection` | included in 398.262 ms | O(document + lines) | No |
|
||||||
|
|
||||||
|
Selection state is correct after Milestone 2.7, but it is not scalable because selection changes are not using cached line boundaries.
|
||||||
|
|
||||||
|
## Typing
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant TextView as "NSTextView"
|
||||||
|
participant Storage as "NSTextStorage"
|
||||||
|
participant Adapter as "Coordinator"
|
||||||
|
participant Dirty as "EditorDirtyLineInvalidator"
|
||||||
|
participant Styler as "MarkdownTextStyler"
|
||||||
|
participant Layout as "NSLayoutManager"
|
||||||
|
participant VM as "ViewModel binding"
|
||||||
|
participant State as "EditorState"
|
||||||
|
|
||||||
|
User->>TextView: "Insert one character"
|
||||||
|
TextView->>Storage: "Native text edit"
|
||||||
|
TextView->>Adapter: "textDidChange"
|
||||||
|
Adapter->>Dirty: "Diff previous/current full text"
|
||||||
|
Dirty->>Dirty: "Build current lines and scan prefix/suffix"
|
||||||
|
Dirty-->>Adapter: "3 dirty lines"
|
||||||
|
Adapter->>Styler: "Style dirty lines"
|
||||||
|
Styler->>Styler: "Rebuild full line list before dirty loop"
|
||||||
|
Styler->>Layout: "TextKit attributes and local layout"
|
||||||
|
Adapter->>VM: "parent.text = textView.string"
|
||||||
|
VM->>State: "updateSource"
|
||||||
|
State->>State: "lineIndex + full line rebuild"
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured typing stages for the 5 MB document:
|
||||||
|
|
||||||
|
| Stage | Time | Budget result | Complexity | Scaling dependency |
|
||||||
|
| --- | ---: | --- | --- | --- |
|
||||||
|
| `text_storage_typing_insert` | 0.779 ms | within budget | local TextKit edit | Edited range |
|
||||||
|
| `dirty_line_invalidation_typing` | 1,019.380 ms | over budget | O(document) | Total document |
|
||||||
|
| `render_update_typing_dirty` | 53.512 ms | over budget | O(document + dirty lines) today | Total document due line-list rebuild |
|
||||||
|
| `layout_after_typing` | 0.916 ms | within budget | local layout after edit | Edited range |
|
||||||
|
| `typing_state_update` | 416.540 ms | over budget | O(document) | Total document |
|
||||||
|
|
||||||
|
Typing is not slow because TextKit cannot insert a character. The native insert and post-edit layout are below 1 ms in the benchmark. Typing is slow because Sapling does full-document dirty invalidation, full-document line-list rebuilding in the styler, and full-document `EditorState.updateSource`.
|
||||||
|
|
||||||
|
## Scrolling
|
||||||
|
|
||||||
|
The benchmark separates cached viewport queries from cold layout-dependent queries.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Scroll["Scroll viewport"] --> Query["glyphRange(forBoundingRect:)"]
|
||||||
|
Query --> Cached{"Layout already generated?"}
|
||||||
|
Cached -->|Yes| Cheap["Cached viewport lookup"]
|
||||||
|
Cached -->|No or target beyond cache| Layout["Generate glyph/layout fragments to requested position"]
|
||||||
|
Layout --> Result["Return glyph range"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured TextKit scroll and layout proxies:
|
||||||
|
|
||||||
|
| Stage | Time | Interpretation |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `cold_viewport_glyph_range_middle` | 38.597 ms | A viewport query before explicit full layout can force layout work |
|
||||||
|
| `cold_line_fragment_calculation_midpoint` | 838.968 ms | Mid-document line-fragment lookup before explicit full layout is document-position-bound |
|
||||||
|
| `layout_generation_full_document` | 1,314.100 ms | Full TextKit layout is the largest single measured stage |
|
||||||
|
| `glyph_generation_full_document` | 200.528 ms | Full glyph generation is document-bound |
|
||||||
|
| `layout_after_typing` | 0.916 ms | Local post-edit layout is cheap after CRLF correctness |
|
||||||
|
| Cached scroll latency measurements | below top-20 threshold for 5 MB | Cached viewport queries are not the measured bottleneck |
|
||||||
|
|
||||||
|
The harness does not prove live scroll-wheel latency in the app. It does prove that TextKit can answer cached viewport queries cheaply, while cold or deep layout-dependent queries can scale with document position and total layout work.
|
||||||
|
|
||||||
|
## Interaction Summary
|
||||||
|
|
||||||
|
| Interaction | Dominant measured work | Current scaling | Meets 16 ms budget? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Document open | Full styling, full render planning, full TextKit layout | Total document | No budget set; multi-second |
|
||||||
|
| Typing | Dirty invalidation + source update + dirty styling setup | Total document | No |
|
||||||
|
| Cursor movement | Selection update + dirty plan + active-line lookup | Total document | No |
|
||||||
|
| Selection changes | Same as cursor movement | Total document | No |
|
||||||
|
| Cached scrolling | TextKit cached glyph-range lookup | Viewport/cached layout | Yes in harness |
|
||||||
|
| Cold/deep layout during scroll | TextKit layout to target position | Document position / layout fragments | No |
|
||||||
|
|
||||||
|
## Scalability Conclusion
|
||||||
|
|
||||||
|
The current editor is correct after Milestone 2.7, but most editor-originated interaction work remains document-bound. TextKit is a major open and cold-layout cost, but the measured typing and cursor bottlenecks are primarily Sapling's repeated full-document line indexing, dirty invalidation, and state rebuilding.
|
||||||
|
|
||||||
252
Docs/editor-scalability-roadmap.md
Normal file
252
Docs/editor-scalability-roadmap.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Editor Scalability Roadmap
|
||||||
|
|
||||||
|
Date: 2026-05-30
|
||||||
|
|
||||||
|
Milestone: 2.8 Editor Scalability
|
||||||
|
|
||||||
|
Status: Recommendations only. No optimization was implemented in this milestone.
|
||||||
|
|
||||||
|
## Decision Context
|
||||||
|
|
||||||
|
D-013 remains valid: NSTextView is still a viable editor foundation. Milestone 2.7 restored correctness by replacing raw Swift `Character("\n")` scanning with `DocumentLineIndex`, so the 5 MB benchmark now segments into 51,482 editor lines and hybrid rendering works.
|
||||||
|
|
||||||
|
Milestone 2.8 shows that correctness did not make the editor responsive enough. The remaining problem is algorithmic shape: interaction paths still include multiple O(document) operations.
|
||||||
|
|
||||||
|
## Ranking Method
|
||||||
|
|
||||||
|
Recommendations are ranked by:
|
||||||
|
|
||||||
|
- measured impact on the 5 MB benchmark
|
||||||
|
- whether the work directly affects typing, cursor movement, selection, or scrolling
|
||||||
|
- implementation complexity and correctness risk
|
||||||
|
- fit with D-013 and the current NSTextView foundation
|
||||||
|
|
||||||
|
## Ranked Opportunities
|
||||||
|
|
||||||
|
| Rank | Opportunity | Expected impact | Complexity | Risk | Evidence |
|
||||||
|
| ---: | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Cache and incrementally update `DocumentLineIndex` inside editor state | Very high for cursor, selection, typing | Medium | Medium | `selection_update` 398.262 ms, `typing_state_update` 416.540 ms, `active_line_lookup` 191.751 ms |
|
||||||
|
| 2 | Replace full-string dirty diffing with edited-range driven invalidation | Very high for typing | Medium | Medium | `dirty_line_invalidation_typing` 1,019.380 ms, `dirty_line_invalidation_click` 224.418 ms |
|
||||||
|
| 3 | Make dirty styling consume cached line boundaries | High for typing and active-line changes | Low-medium | Low-medium | `render_update_typing_dirty` 53.512 ms for only 3 styled lines because line list is rebuilt first |
|
||||||
|
| 4 | Cache render plans by line identity and invalidate by dirty line | High for opening and active-line transitions | Medium | Medium | Full render-plan generation is 611.781 ms |
|
||||||
|
| 5 | Reduce forced TextKit full-layout work | High for open and cold/deep scroll positions | High | Medium-high | `layout_generation_full_document` 1,314.100 ms and cold midpoint line fragment 838.968 ms |
|
||||||
|
| 6 | Introduce viewport-bounded styling/render scheduling | Potentially very high for 100k+ lines | High | High | Current styling and render planning are document-bound on initial load |
|
||||||
|
| 7 | Add visible-app scroll and Time Profiler traces before layout changes | High diagnostic value | Medium | Low | Benchmark does not reproduce live scroll-wheel latency |
|
||||||
|
|
||||||
|
## 1. Cached Incremental Line Index
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Selection["Selection or source change"] --> Lookup["lineIndex(containing:)"]
|
||||||
|
Lookup --> Scan["Scan source with DocumentLineIndex"]
|
||||||
|
Selection --> Lines["lines(from:)"]
|
||||||
|
Lines --> Rebuild["Rebuild all EditorLine values"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Target behavior:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Source["Source edit"] --> Index["Persistent DocumentLineIndex"]
|
||||||
|
Index --> Incremental["Update affected boundaries"]
|
||||||
|
Selection["Selection change"] --> Query["Query cached boundaries"]
|
||||||
|
Query --> Active["Active line in O(log lines) or O(1) with cursor locality"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- Cursor movement should stop paying the 191.751 ms active-line scan and most of the 398.262 ms selection update.
|
||||||
|
- Typing should avoid rebuilding 51,482 `EditorLine` values when the edit affects one line.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- Medium. The editor needs a single owned line index and invalidation rules for insertions, deletions, and line-ending changes.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Medium. Milestone 2.7 showed newline handling is correctness-critical. The cached index must preserve LF, CRLF, CR, mixed endings, and trailing blank lines.
|
||||||
|
|
||||||
|
## 2. Edited-Range Dirty Invalidation
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Edit["One-character edit"] --> Diff["Compare previous/current full strings"]
|
||||||
|
Diff --> Prefix["Scan common prefix"]
|
||||||
|
Diff --> Suffix["Scan common suffix"]
|
||||||
|
Diff --> Lines["Rebuild current full line list"]
|
||||||
|
Lines --> Dirty["Return 3 dirty lines"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Target behavior:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
TextKit["Native edited range"] --> Index["Cached line index"]
|
||||||
|
Index --> Dirty["Affected line plus neighbors"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- Directly targets the largest typing-stage cost: 1,019.380 ms.
|
||||||
|
- Also removes the 224.418 ms click-path dirty-plan cost when the source did not change.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- Medium. NSTextView/NSTextStorage already knows edited ranges, but the adapter needs to pass them through cleanly without destabilizing SwiftUI bindings.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Medium. Invalidation must still include neighboring lines when Markdown interpretation can be affected by adjacent content.
|
||||||
|
|
||||||
|
## 3. Dirty Styling With Cached Line Boundaries
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
`MarkdownTextStyler.apply` receives a dirty-line plan, but it first calls `EditorActiveLineTracker.lines(from:)` across the full text. The 5 MB typing path styles only 3 lines but still costs 53.512 ms.
|
||||||
|
|
||||||
|
Target behavior:
|
||||||
|
|
||||||
|
- Resolve dirty line indexes through the cached line index.
|
||||||
|
- Avoid constructing `EditorLine` values for lines outside the dirty set.
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- Should bring dirty styling closer to the local TextKit edit and layout costs, which are below 1 ms in the benchmark.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- Low-medium if implemented after cached line index ownership exists.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Low-medium. Rendering behavior should remain unchanged if line ranges come from the same `DocumentLineIndex`.
|
||||||
|
|
||||||
|
## 4. Render Plan Cache
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
Full render-plan generation across 51,482 lines costs 611.781 ms. Dirty rendering currently runs render plans only for styled lines, but initial render and future full refreshes remain document-bound.
|
||||||
|
|
||||||
|
Target behavior:
|
||||||
|
|
||||||
|
- Cache `HybridMarkdownLineRenderPlan` by line range/content generation.
|
||||||
|
- Recompute plans only for dirty lines and neighboring affected lines.
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- Improves initial refreshes, active-line transitions, and any future operation that currently schedules broad render planning.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- Medium. Needs cache invalidation tied to source edits and line identity.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Medium. Incorrect cache identity could show stale Markdown styling.
|
||||||
|
|
||||||
|
## 5. TextKit Full-Layout Reduction
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
| --- | ---: |
|
||||||
|
| `layout_generation_full_document` | 1,314.100 ms |
|
||||||
|
| `cold_line_fragment_calculation_midpoint` | 838.968 ms |
|
||||||
|
| `glyph_generation_full_document` | 200.528 ms |
|
||||||
|
| `cold_viewport_glyph_range_middle` | 38.597 ms |
|
||||||
|
| `layout_after_typing` | 0.916 ms |
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
The benchmark explicitly forces full layout to measure worst-case TextKit cost. Cached viewport queries are cheap, but cold/deep layout queries can require significant work.
|
||||||
|
|
||||||
|
Potential directions:
|
||||||
|
|
||||||
|
- Avoid explicit full-document `ensureLayout` in interaction paths.
|
||||||
|
- Confirm whether the live app forces layout via `scrollRangeToVisible`, selection restoration, status updates, accessibility, or full attribute changes.
|
||||||
|
- Use Instruments or a visible AppKit scroll benchmark before changing layout strategy.
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- High for document open, cold scroll to deep positions, and any operation that invalidates broad layout.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- High because TextKit behavior is platform-managed and easy to perturb.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Medium-high. Selection stability and scroll preservation depend on native TextKit behavior.
|
||||||
|
|
||||||
|
## 6. Viewport-Bounded Styling And Rendering
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Source["Full document"] --> Lines["All editor lines"]
|
||||||
|
Lines --> Plans["All render plans on full render"]
|
||||||
|
Lines --> Styling["Styling receives full line list"]
|
||||||
|
Styling --> TextKit["TextStorage attributes"]
|
||||||
|
TextKit --> Viewport["Viewport"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Potential target:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Source["Full document"] --> Index["Cached line index"]
|
||||||
|
Viewport["Visible rect + buffer"] --> VisibleLines["Visible line ranges"]
|
||||||
|
Dirty["Dirty edit ranges"] --> WorkSet["Visible lines union dirty lines union active line"]
|
||||||
|
Index --> WorkSet
|
||||||
|
WorkSet --> Styling["Bounded styling/render planning"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- Potentially the largest improvement for 100k+ line documents.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- High. Requires careful coordination among visible range calculation, active line rendering, offscreen selection, search, accessibility, and full-document export/print scenarios.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- High. This should follow lower-risk line-index and dirty-invalidation work, not precede it.
|
||||||
|
|
||||||
|
## 7. Visible-App Scroll And Time Profiler Traces
|
||||||
|
|
||||||
|
The benchmark proves that cached glyph-range lookup is cheap and cold layout can be expensive. It does not prove the exact source of subjective live scroll slowness.
|
||||||
|
|
||||||
|
Next measurement work:
|
||||||
|
|
||||||
|
- capture a visible Sapling scroll trace for the 5 MB document
|
||||||
|
- rank CPU stacks during scroll-wheel events
|
||||||
|
- determine whether scrolling triggers selection updates, styling, layout invalidation, drawing, or accessibility work
|
||||||
|
|
||||||
|
Expected impact:
|
||||||
|
|
||||||
|
- High diagnostic value before any TextKit-level changes.
|
||||||
|
|
||||||
|
Complexity:
|
||||||
|
|
||||||
|
- Medium because it requires an app-level profiling workflow.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Low. This is measurement-only.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
The next implementation milestone should begin with cached, incrementally updated line indexing and edited-range dirty invalidation. These target the largest interaction costs without violating D-013 or replacing NSTextView.
|
||||||
|
|
||||||
|
Viewport-only styling and TextKit layout strategy changes should wait until after the document-bound editor-side scans are removed. Otherwise TextKit measurements will stay mixed with avoidable Sapling work.
|
||||||
|
|
||||||
|
## 100k+ Line Assessment
|
||||||
|
|
||||||
|
The current architecture cannot feel responsive at 100k+ lines while cursor movement, selection updates, typing updates, dirty invalidation, and dirty styling setup remain O(document). NSTextView is not disproven by the current data, but Sapling's current interaction pipeline must become line-index cached and edited-range driven before 100k+ line support is realistic.
|
||||||
|
|
||||||
|
|
@ -425,15 +425,134 @@ Evidence ranks after the CRLF fix:
|
||||||
|
|
||||||
File I/O is not the bottleneck. `file_read` was 3.651 ms in the corrected benchmark.
|
File I/O is not the bottleneck. `file_read` was 3.651 ms in the corrected benchmark.
|
||||||
|
|
||||||
|
## Milestone 2.8 Scalability Update
|
||||||
|
|
||||||
|
Milestone 2.8 added TextKit probes for cold viewport layout and line-fragment calculation. The corrected 5 MB benchmark still segments into 51,482 editor lines and schedules 51,481 rendered lines plus one active source line.
|
||||||
|
|
||||||
|
Release benchmark run on May 30, 2026:
|
||||||
|
|
||||||
|
| Rank | Operation | Category | Time | Percent | Evidence |
|
||||||
|
| ---: | --- | --- | ---: | ---: | --- |
|
||||||
|
| 1 | `layout_generation_full_document` | layout | 1,314.100 ms | 20.102% | `NSLayoutManager.ensureLayout` for the full text container |
|
||||||
|
| 2 | `dirty_line_invalidation_typing` | editor update | 1,019.380 ms | 15.593% | 3 dirty lines returned after full-document work |
|
||||||
|
| 3 | `cold_line_fragment_calculation_midpoint` | layout | 838.968 ms | 12.834% | midpoint `lineFragmentRect` before explicit full layout |
|
||||||
|
| 4 | `attributed_string_generation_initial` | rendering | 787.685 ms | 12.049% | 51,482 styled lines |
|
||||||
|
| 5 | `render_plan_generation_all_lines` | rendering | 611.781 ms | 9.358% | 51,482 render plans |
|
||||||
|
| 6 | `typing_state_update` | interaction | 416.540 ms | 6.372% | `EditorState.updateSource` after one-character insertion |
|
||||||
|
| 7 | `selection_update` | editor update | 398.262 ms | 6.092% | `EditorState.updateSelection` rebuilds line model |
|
||||||
|
| 8 | `dirty_line_invalidation_click` | editor update | 224.418 ms | 3.433% | 0 dirty lines, but current lines are rebuilt first |
|
||||||
|
| 9 | `document_parse_editor_state_init` | document load | 217.066 ms | 3.320% | initial `EditorState` |
|
||||||
|
| 10 | `line_model_generation` | editor update | 205.885 ms | 3.149% | `EditorActiveLineTracker.lines` over full source |
|
||||||
|
| 11 | `glyph_generation_full_document` | layout | 200.528 ms | 3.067% | full character range |
|
||||||
|
| 12 | `active_line_lookup` | editor update | 191.751 ms | 2.933% | midpoint cursor lookup |
|
||||||
|
| 13 | `render_update_typing_dirty` | interaction | 53.512 ms | 0.819% | 3 styled lines |
|
||||||
|
| 14 | `cold_viewport_glyph_range_middle` | layout | 38.597 ms | 0.590% | viewport query before explicit full layout |
|
||||||
|
| 15 | `text_storage_initial_update` | layout | 14.469 ms | 0.221% | full source replace |
|
||||||
|
| 16 | `file_read` | document load | 2.525 ms | 0.039% | disk read |
|
||||||
|
| 17 | `layout_after_typing` | interaction | 0.916 ms | 0.014% | layout for inserted character |
|
||||||
|
| 18 | `text_storage_typing_insert` | interaction | 0.779 ms | 0.012% | one-character insertion |
|
||||||
|
| 19 | `document_model_init` | document load | 0.035 ms | 0.001% | `MarkdownDocument` |
|
||||||
|
| 20 | `layout_invalidation_full_document` | layout | 0.013 ms | 0.000% | explicit full layout invalidation call |
|
||||||
|
|
||||||
|
Measured total: 6,537.243 ms.
|
||||||
|
|
||||||
|
The measured total increased relative to the Milestone 2.7 table because Milestone 2.8 added two cold TextKit probes. It should not be compared as an optimization regression.
|
||||||
|
|
||||||
|
### Scalability Map
|
||||||
|
|
||||||
|
| Subsystem | Trigger | Frequency | Complexity | Document dependency | Viewport dependency | Evidence |
|
||||||
|
| --- | --- | ---: | --- | --- | --- | --- |
|
||||||
|
| File read | document open | once | O(bytes) | yes | no | 2.525 ms |
|
||||||
|
| `DocumentLineIndex` construction | open, state update, dirty planning, styling | repeated | O(UTF-16 units + lines) | yes | no | `line_model_generation` 205.885 ms |
|
||||||
|
| Active-line lookup | cursor/source/selection changes | every relevant interaction | O(UTF-16 units + lines) today | yes | no | 191.751 ms |
|
||||||
|
| `EditorState.updateSelection` | click, cursor movement, selection changes | every selection change | O(document) | yes | no | 398.262 ms |
|
||||||
|
| `EditorState.updateSource` | typing | every source edit | O(document) | yes | no | 416.540 ms |
|
||||||
|
| Dirty invalidation | typing, active-line changes, view updates | every styling attempt | O(document) | yes | no | 1,019.380 ms typing; 224.418 ms click |
|
||||||
|
| Full render planning | initial/full render | on full plan | O(lines + inline parse work) | yes | no | 611.781 ms |
|
||||||
|
| Dirty render planning | dirty render | per dirty line after plan | O(dirty lines) after line lookup | partly | dirty set, not viewport | 3 styled lines, but 53.512 ms due full line-list setup |
|
||||||
|
| Initial attributed styling | initial render | once per load | O(document + lines) | yes | no | 787.685 ms |
|
||||||
|
| `NSTextStorage` full replace | text load | once | O(document) | yes | no | 14.469 ms |
|
||||||
|
| `NSTextStorage` edit | typing | every edit | local edit | no in benchmark | no | 0.779 ms |
|
||||||
|
| Full glyph generation | initial/full layout | on full layout | O(document) | yes | no | 200.528 ms |
|
||||||
|
| Full layout generation | initial/full layout | on full layout | O(layout fragments) | yes | no | 1,314.100 ms |
|
||||||
|
| Cold viewport glyph range | scroll/query before layout cache exists | as needed | O(layout to queried rect) | yes, by position | yes | 38.597 ms |
|
||||||
|
| Cold line fragment lookup | selection/layout query before layout cache exists | as needed | O(layout to queried character) | yes, by position | no | 838.968 ms |
|
||||||
|
| Cached viewport queries | scroll after layout cache exists | per query | near O(visible query) | no proven dependency | yes | below top-20 threshold for 5 MB |
|
||||||
|
|
||||||
|
### Performance Budget
|
||||||
|
|
||||||
|
| Interaction | Target | Current measured path | Result |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| Typing | <16 ms | 1,019.380 ms dirty invalidation + 416.540 ms source update + 53.512 ms dirty styling | fails |
|
||||||
|
| Cursor movement | <16 ms | 398.262 ms selection update + 224.418 ms dirty plan + 191.751 ms active-line lookup proxy | fails |
|
||||||
|
| Selection changes | <16 ms | same path as cursor movement | fails |
|
||||||
|
| Cached scroll query | <16 ms | below top-20 threshold after layout cache | passes in harness |
|
||||||
|
| Cold/deep layout query | <16 ms | 838.968 ms midpoint line fragment; 38.597 ms cold viewport | fails |
|
||||||
|
| Document open | documented, not 16 ms | full styling 787.685 ms + full layout 1,314.100 ms + full render planning 611.781 ms | multi-second |
|
||||||
|
|
||||||
|
### Document-Bound Operations
|
||||||
|
|
||||||
|
The following operations still scale with total document size:
|
||||||
|
|
||||||
|
| Operation | Why it exists | Necessary today? | Could become viewport-bound? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `EditorState.updateSelection` full line rebuild | Keeps active line and status bar line state coherent | yes with current state shape | yes, by caching line index and deriving active line without rebuilding all `EditorLine` values |
|
||||||
|
| `EditorState.updateSource` full line rebuild | Recomputes line ranges and active/source modes after edits | yes with current state shape | partly, by incrementally updating line index and only materializing affected lines |
|
||||||
|
| Dirty invalidator full prefix/suffix diff | Finds changed range without native edited range input | yes with current API | yes, by passing TextKit edited ranges into invalidation |
|
||||||
|
| Dirty invalidator current-line rebuild | Maps changed range to dirty line indexes | yes with current API | yes, by querying cached line boundaries |
|
||||||
|
| `MarkdownTextStyler.apply` full line-list setup | Converts text storage string to `EditorLine` values before applying dirty attributes | yes with current API | yes, by resolving only dirty line ranges from cached line index |
|
||||||
|
| Full render-plan generation | Creates a render plan for every line in full render scenarios | yes for current initial full render | yes for interaction paths; initial full render may remain document-bound unless viewport rendering is introduced |
|
||||||
|
| Initial attributed styling | Applies base attributes and hybrid styling to all lines | yes today | yes, with viewport-bounded styling plus background/full-export handling |
|
||||||
|
| Full TextKit layout | Calculates layout fragments across the text container | not always necessary for interaction | partly, by avoiding forced full layout and validating viewport/deep-scroll behavior |
|
||||||
|
|
||||||
|
### TextKit Findings
|
||||||
|
|
||||||
|
TextKit is a real bottleneck for full-document and cold/deep layout work:
|
||||||
|
|
||||||
|
- `layout_generation_full_document`: 1,314.100 ms
|
||||||
|
- `cold_line_fragment_calculation_midpoint`: 838.968 ms
|
||||||
|
- `glyph_generation_full_document`: 200.528 ms
|
||||||
|
- `cold_viewport_glyph_range_middle`: 38.597 ms
|
||||||
|
|
||||||
|
TextKit is not the measured bottleneck for local typing once layout is cached:
|
||||||
|
|
||||||
|
- `text_storage_typing_insert`: 0.779 ms
|
||||||
|
- `layout_after_typing`: 0.916 ms
|
||||||
|
|
||||||
|
Conclusion: TextKit dominates opening and cold layout-to-position work. Sapling's own full-document scans dominate typing, cursor movement, and selection changes.
|
||||||
|
|
||||||
|
### Viewport Assessment
|
||||||
|
|
||||||
|
Sapling is not currently viewport-bound for editor state, dirty invalidation, render-plan setup, or styling setup. It becomes viewport-like only after TextKit has a layout cache and the benchmark asks for a visible glyph range.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Text["Full source"] --> State["EditorState lines"]
|
||||||
|
Text --> Dirty["Dirty invalidator"]
|
||||||
|
Text --> Styler["MarkdownTextStyler line setup"]
|
||||||
|
State --> Render["Render planning"]
|
||||||
|
Styler --> TextKit["TextStorage attributes"]
|
||||||
|
TextKit --> Layout["TextKit layout cache"]
|
||||||
|
Layout --> Viewport["Cached viewport glyph query"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The core scalability question has this answer:
|
||||||
|
|
||||||
|
- Typing, cursor movement, selection changes, active-line lookup, dirty invalidation, initial styling, and full render planning scale with total document size.
|
||||||
|
- Cached viewport glyph lookup scales with the viewport after layout exists.
|
||||||
|
- Cold/deep TextKit layout queries scale with layout work required to reach the queried position.
|
||||||
|
|
||||||
## Recommendations For Future Work
|
## Recommendations For Future Work
|
||||||
|
|
||||||
These are recommendations from evidence, not implemented changes:
|
These are recommendations from evidence, not implemented changes:
|
||||||
|
|
||||||
1. Remove full-document line-model rebuilding from selection changes. Cursor movement should not rebuild every `EditorLine`.
|
1. Cache and incrementally update `DocumentLineIndex` inside editor state.
|
||||||
2. Change dirty invalidation to use native edited ranges instead of prefix/suffix diffing full strings on every edit.
|
2. Change dirty invalidation to use native edited ranges instead of prefix/suffix diffing full strings on every edit.
|
||||||
3. Change `MarkdownTextStyler.apply` so dirty rendering does not recompute the full line list before styling dirty ranges.
|
3. Change `MarkdownTextStyler.apply` so dirty rendering does not recompute the full line list before styling dirty ranges.
|
||||||
4. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after correct CRLF handling.
|
4. Cache render plans by line identity and invalidate only affected lines.
|
||||||
5. Keep `DocumentLineIndex` as the single source for editor line segmentation; future editor systems should not search raw Swift `String` values for newline characters.
|
5. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after correct CRLF handling.
|
||||||
|
6. Defer viewport-only styling and TextKit layout strategy changes until document-bound editor-side scans are removed.
|
||||||
|
7. Keep `DocumentLineIndex` as the single source for editor line segmentation; future editor systems should not search raw Swift `String` values for newline characters.
|
||||||
|
|
||||||
## Reproduction Commands
|
## Reproduction Commands
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue