perf: script editor render pipeline and caching#199
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FormattedTextreuse 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.OnDataContextChangeddedupe fix inScriptTextEditorand removes the idle-cadence apparatus once the layered pipeline made it redundant.Test plan
dotnet test— 1187 tests passing, including new coverage forCachedMultiLineRangesand the sealed-line cache.Merge order
Suggested to merge after #198 (the calc feature PR). Both branches are independent off
masterand rebase cleanly either way; calc-first preserves the development order under which this perf work was originally written and tested.