Skip to content

perf: script editor render pipeline and caching#199

Merged
fuzzzerd merged 13 commits intomasterfrom
fuzzz/script-editor-perf
Apr 26, 2026
Merged

perf: script editor render pipeline and caching#199
fuzzzerd merged 13 commits intomasterfrom
fuzzz/script-editor-perf

Conversation

@fuzzzerd
Copy link
Copy Markdown
Owner

@fuzzzerd fuzzzerd commented Apr 26, 2026

Summary

  • Collapses four overlapping AvaloniaEdit background renderers into a single shared pipeline with composable layers (bracket match, continuation rail, error marker, statement highlight, sealed step), eliminating duplicated invalidation work and redundant per-line passes.
  • Adds skip-when-unchanged caching: multi-line statement ranges keyed by document version, FormattedText reuse in the step-index margin, error-marker invalidation gated on diagnostics actually moving, and bracket-match allocation skipped when the caret is off a bracket.
  • Resolves the editor font to a real installed face name, collapses the font family to a single name, and skips the built-in line-number margin we draw ourselves — all measurable startup/repaint wins.
  • Includes a small OnDataContextChanged dedupe fix in ScriptTextEditor and removes the idle-cadence apparatus once the layered pipeline made it redundant.

Test plan

  • dotnet test — 1187 tests passing, including new coverage for CachedMultiLineRanges and the sealed-line cache.
  • Manually scroll and edit a large script in the script editor; confirm sealed-step dimming, error squiggles, bracket match, statement highlight, and the step-index margin all still paint correctly with less repaint churn.

Merge order

Suggested to merge after #198 (the calc feature PR). Both branches are independent off master and rebase cleanly either way; calc-first preserves the development order under which this perf work was originally written and tested.

fuzzzerd added 13 commits April 26, 2026 18:22
MultiLineStatementRanges.Compute is pure on document text, but every
renderer + caret handler called it independently — at least four
full-document scans per edit cycle (StatementHighlightRenderer.Draw,
ContinuationLineRenderer.Draw, StatementHighlightRenderer.UpdateHighlight
on every caret move, StepIndexMargin via BuildStepIndex).

CachedMultiLineRanges wraps the compute with a per-document version
cache backed by ConditionalWeakTable. First caller in an edit cycle
runs the compute; subsequent callers reuse the result. Step-index
lookup is cached jointly so StepIndexMargin no longer rewalks the
ranges either.

Plus a caret-line skip in StatementHighlightRenderer.UpdateHighlight:
PositionChanged fires on every keystroke and arrow key, but the
highlighted *step* only changes when the caret moves to a different
physical line. Skipping in-line caret movement eliminates the most
common path that previously triggered Compute.

Combined effect on the trace's hot frames: 4× redundant Compute
calls collapse to 1, and the per-keystroke caret handler no longer
hits Compute at all. Renderers that cannot use the cached doc-based
overload (ScriptValidator on a background snapshot string,
ContinuationIndentStrategy on a single Enter event) keep using the
existing string overload — neither is hot.
After the MultiLineStatementRanges cache landed, the next-tier
trace shows StepIndexMargin.Render at 127ms inclusive over 30s,
with 16ms in MakeFormattedText alone — Avalonia's FormattedText
construction does font shaping + glyph layout per call. Step
numbers are bounded ("1", "2", "3", …) so cache by string and
clear when the typeface or em size changes.

Separately, BracketMatchRenderer.UpdateBracketMatch reads
doc.Text (full-document string allocation) on every caret move
even when the caret isn't on a bracket — which is most caret
positions. Cheaply check the char-before / char-at offsets first
and defer the doc.Text read until we know we're actually near
a bracket.
UpdateDiagnostics now returns a bool indicating whether the new
list actually differs from the previous one. The validation
RunValidation path only calls InvalidateLayer when the answer is
yes — steady typing inside a line that's still good (or bad in the
same way) returns identical lists every cycle, and the invalidation
forced a full TextView render pass each time.
Avalonia fires DataContextChanged multiple times during template
instantiation and visual-tree reparenting even when the resolved
value is the same instance. The trace showed it firing 9× for a
single open tab, each time tearing down and reinstalling sealed-
step renderers (squiggle + italic + cog) plus reattaching the
read-only provider.

Track the currently attached ScriptClipEditor and skip the work
when the resolved DataContext is the same instance as before.
Three sealed-step components fire per visible line per layout
(SealedStepItalicColorizer.ColorizeLine) or per paint
(SealedStepSquiggleRenderer.Draw, SealedStepCogGenerator.
GetFirstInterestedOffset). Each iterates _editor.SealedAnchors,
which allocates a Where+Select enumerator and runs SignatureMatches
(Document.GetText) per anchor — fine when something actually needs
processing, but pure overhead when the script has zero sealed
steps. With ~10 visible lines and hundreds of layouts in a 30-
second window, the empty-loop iterations add up.

Add a cheap HasSealedAnchors getter on ScriptClipEditor (one
dictionary Count check) and short-circuit at the top of each
sealed-step component when it returns false.

While in SealedStepItalicColorizer, also fix two amplifiers that
trigger Avalonia text-shaping work redundantly:

  - Cache the italic Typeface struct per FontFamily. The original
    code allocated a fresh Typeface(family, FontStyle.Italic) per
    text run per visible line per layout — each fresh struct drives
    a glyph-typeface lookup, defeating Avalonia's font cache.
  - Skip SetTypeface entirely when the run is already italic.
    Avalonia treats every SetTypeface call as a state change that
    re-runs ShapeTextRuns + glyph-typeface resolution, even when
    the value is unchanged.

The trace showed Avalonia spending ~15s of CPU on font/typeface
resolution during a 30-second typing window — the chief amplifier
of "we're doing nothing visible but the editor still feels slow".
VisualLinesChanged fires on every layout pass — including ones
where neither the visible line range nor the document's step
structure changed (e.g. typing a character within a step,
toggling focus). Each unconditional InvalidateVisual schedules a
render frame; the render itself is now cheap (FormattedText is
cached) but scheduling still cascades through the layout system.

Track the last (first, last) visible line numbers we rendered
plus the step-index dict identity (CachedMultiLineRanges returns
the same instance across version-matched calls, so reference
equality reads as "doc structure didn't change"). Skip
InvalidateVisual when all three match the previous paint — the
next paint would be pixel-identical anyway.
Replace BracketMatchRenderer, StatementHighlightRenderer,
ContinuationLineRenderer, and ErrorMarkerRenderer with a unified
ScriptEditorRenderPipeline that owns:

  - One RenderContext (TextArea, Caret, cached statement ranges,
    diagnostics) shared across all feature layers.
  - Four IRenderLayer implementations — StatementHighlightLayer,
    ContinuationRailLayer, BracketMatchLayer, ErrorMarkerLayer —
    each owning only its own dirty state.
  - Two LayeredBackgroundRenderer wrappers, one per AvaloniaEdit
    KnownLayer (Background + Selection), each hosting the layers
    that target it. Visual stacking order preserved.
  - One Caret.PositionChanged subscription (was four). Layers are
    dispatched once per event and report whether their draw output
    would differ; the pipeline accumulates dirty KnownLayers and
    issues at most one InvalidateLayer call per affected target.

Beyond the cleaner architecture, the practical perf win is that
multiple feature layers reporting dirty on the same KnownLayer no
longer force separate InvalidateLayer / repaint cycles — they
collapse into a single render pass.

ScriptEditorController.RunValidation hands diagnostics through
ScriptEditorRenderPipeline.UpdateDiagnostics, which tracks list
identity and only invalidates the Selection layer on a real change.
The pointer-hover tooltip path queries diagnostics via
GetDiagnosticAtOffset instead of the deleted ErrorMarkerRenderer.

The four old renderer files are removed; their logic now lives in
the per-layer classes under Scripting/Editor/Pipeline/.
Sealed-step rendering was the dominant per-keystroke cost. The italic
colorizer mutated TextRunProperties.Typeface per text run per visible
line per layout, driving a ShapeTextRuns + font-resolution cascade.
The squiggle renderer and cog generator both walked the SealedAnchors
LINQ chain per paint/per visual-line, allocating a fresh string per
anchor through SignatureMatches.

Changes:
- ScriptClipEditor caches HashSet<int> SealedLineNumbers and a
  Dictionary<int,int> SealedLineEndOffsets, invalidated on
  Document.TextChanged. Renderers read O(1) from the cache instead of
  iterating anchors and allocating strings per call.
- IRenderLayer gains a RenderCadence (Realtime|Idle) and an
  OnTextChanged hook. The pipeline subscribes to Document.TextChanged
  on a 150ms debounce and dispatches idle layers; realtime layers keep
  using the existing caret path.
- New SealedStepLayer (Idle, Selection) replaces the standalone
  SealedStepSquiggleRenderer. Draws the existing zigzag plus a left-
  edge accent stripe.
- SealedStepDimmingColorizer (renamed from SealedStepItalicColorizer)
  applies a dimmed foreground brush instead of flipping typeface.
  Foreground brush changes are paint-time only; no shape/font work.
- SealedStepCogGenerator reads end offsets from the cached dict.
- RenderContext exposes the sealed snapshot to layers so they read
  the same data via the same surface as StatementRanges/Diagnostics.
Trace showed LineNumberMargin.Render consuming 5.8s/30s. Factory was
setting ShowLineNumbers=true and the controller was flipping it off,
but the margin can re-attach when the editor template re-applies. The
controller installs StepIndexMargin regardless, so set it to false up
front.
Trace showed 2321 native font lookups in 30s — about 8.7 per visual-
line build. Cause: the editor's font family was a comma-separated
fallback chain "Cascadia Code,Consolas,Menlo,Monospace". Three of
those four names don't exist on this Linux box (fc-match resolves
Cascadia/Menlo to Noto Sans, a proportional face); every text-run
shape pass was walking the chain looking for missing fonts before
landing on the resolved face. Fontconfig's generic "Monospace" alias
resolves directly to the platform's default monospace face on each
OS, so a single name replaces the chain without losing portability.
The trace showed 1283 typeface lookups per 30s, ~100% of which missed
Avalonia's font cache. Cause: SystemFontCollection's cache is keyed
by the literal family-name string we ask for, but populated under the
RESOLVED face name. Asking for the fontconfig alias "Monospace" never
hits a cache entry stored under "DejaVu Sans Mono" — every text-run
shape pass re-runs the full system-font lookup.

Probe FontManager.Current.SystemFonts at startup and pick the first
entry from a platform-appropriate preference list (Cascadia/Consolas
on Windows, Menlo/Monaco on macOS, JetBrains Mono / DejaVu Sans Mono
on Linux). With a real face name in the editor's FontFamily, the
second and every subsequent typeface lookup is a hashtable hit.

A user-visible font picker is a follow-up; this lands the perf fix.
Once the editor font was resolved to a real installed face name,
typeface lookups dropped 99% and every "expensive" path on the
caret/text-change side became cheap enough that idle scheduling
saves nothing measurable. Strip the complexity:

- Remove RenderCadence enum, OnTextChanged from IRenderLayer, and
  the per-layer Cadence/OnTextChanged overrides.
- Remove the DispatcherTimer, AttachDocument/OnDocumentTextChanged,
  OnIdleTick, OnTextAreaDocumentChanged, ScheduleIdleRecompute, and
  SealedCacheChanged event plumbing.
- SealedStepLayer reads the sealed-line snapshot directly during
  Draw — no _lastLineSet, no diffing, no idle dispatch.
- AttachClipEditor still wires the snapshot into RenderContext and
  invalidates the Selection layer once so the new clip's stripe +
  squiggle paint immediately.

The shared sealed-line cache on ScriptClipEditor stays — it still
saves per-paint string allocations through SignatureMatches. The
event raise on cache invalidation is gone since nothing subscribes.

Net: -162 lines.
Two bits of pure-logic infra that drive the script editor's render
path with no UI of their own:

- ScriptClipEditor.SealedLineNumbers / SealedLineEndOffsets — the
  hot-path snapshot read by the dimming colorizer, cog generator,
  and sealed-step layer. Tests cover initial population, the empty
  case, line-number tracking through inserts, and entry pruning on
  whole-line deletion.

- CachedMultiLineRanges — the per-document range cache reused by
  the continuation rail, statement highlight, and step-index margin.
  Tests cover the reference-equality contract (same version → same
  instance) and joint invalidation of ranges + step-index on edits.

Drop the [ExcludeFromCodeCoverage] from CachedMultiLineRanges now
that it has direct coverage.
@github-actions
Copy link
Copy Markdown

Test Results

✔️ Tests 1187 / 1187 - passed in 11.5s
✔️ Coverage 77.07% - passed with 70% threshold
📏 13480 / 15982 lines covered 🌿 4682 / 7585 branches covered
🔍 click here for more details

✏️ updated for commit 1724e5d

@fuzzzerd fuzzzerd enabled auto-merge (rebase) April 26, 2026 23:33
@fuzzzerd fuzzzerd merged commit 2e82b58 into master Apr 26, 2026
8 of 9 checks passed
@fuzzzerd fuzzzerd deleted the fuzzz/script-editor-perf branch April 26, 2026 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant