Implement TextRunCache#21030
Conversation
API diff between 12.0.999-cibuild0064200-alpha and 12.0.999Avalonia.Base (net10.0, net8.0) namespace Avalonia.Media.TextFormatting
{
public abstract class TextFormatter
{
+ public virtual Avalonia.Media.TextFormatting.TextLine? FormatLine(Avalonia.Media.TextFormatting.ITextSource textSource, int? firstTextSourceIndex, double? paragraphWidth, Avalonia.Media.TextFormatting.TextParagraphProperties paragraphProperties, Avalonia.Media.TextFormatting.TextLineBreak? previousLineBreak, Avalonia.Media.TextFormatting.TextRunCache? textRunCache);
}
public class TextLayout
{
- public TextLayout(Avalonia.Media.TextFormatting.ITextSource textSource, Avalonia.Media.TextFormatting.TextParagraphProperties paragraphProperties, Avalonia.Media.TextTrimming? textTrimming = null, double maxWidth = global::System.Double.PositiveInfinity, double maxHeight = global::System.Double.PositiveInfinity, int maxLines = 0);
- public TextLayout(string? text, Avalonia.Media.Typeface? typeface, double? fontSize = 12, Avalonia.Media.IBrush? foreground = null, Avalonia.Media.TextAlignment? textAlignment = 0, Avalonia.Media.TextWrapping? textWrapping = 0, Avalonia.Media.TextTrimming? textTrimming = null, Avalonia.Media.TextDecorationCollection? textDecorations = null, Avalonia.Media.FlowDirection? flowDirection = 0, double? maxWidth = global::System.Double.PositiveInfinity, double? maxHeight = global::System.Double.PositiveInfinity, double? lineHeight = global::System.Double.NaN, double? letterSpacing = 0, int? maxLines = 0, Avalonia.Media.FontFeatureCollection? fontFeatures = null, System.Collections.Generic.IReadOnlyList<Avalonia.Utilities.ValueSpan<Avalonia.Media.TextFormatting.TextRunProperties>>? textStyleOverrides = null);
+ public TextLayout(Avalonia.Media.TextFormatting.ITextSource textSource, Avalonia.Media.TextFormatting.TextParagraphProperties paragraphProperties, Avalonia.Media.TextTrimming? textTrimming = null, double maxWidth = global::System.Double.PositiveInfinity, double maxHeight = global::System.Double.PositiveInfinity, int maxLines = 0, Avalonia.Media.TextFormatting.TextRunCache? textRunCache = null);
+ public TextLayout(string? text, Avalonia.Media.Typeface? typeface, double? fontSize = 12, Avalonia.Media.IBrush? foreground = null, Avalonia.Media.TextAlignment? textAlignment = 0, Avalonia.Media.TextWrapping? textWrapping = 0, Avalonia.Media.TextTrimming? textTrimming = null, Avalonia.Media.TextDecorationCollection? textDecorations = null, Avalonia.Media.FlowDirection? flowDirection = 0, double? maxWidth = global::System.Double.PositiveInfinity, double? maxHeight = global::System.Double.PositiveInfinity, double? lineHeight = global::System.Double.NaN, double? letterSpacing = 0, int? maxLines = 0, Avalonia.Media.FontFeatureCollection? fontFeatures = null, System.Collections.Generic.IReadOnlyList<Avalonia.Utilities.ValueSpan<Avalonia.Media.TextFormatting.TextRunProperties>>? textStyleOverrides = null, Avalonia.Media.TextFormatting.TextRunCache? textRunCache = null);
}
+ public class TextRunCache
+ {
+ public TextRunCache();
+ public void Dispose();
+ public void Invalidate();
+ public void InvalidateFrom(int textSourceIndex);
+ }
} |
|
You can test this PR using the following package version. |
TextRunCache Benchmark AnalysisDate: 2026-04-02 Raw Results
Speed Improvements (Cache vs No Cache)
Memory Reduction (Cache vs No Cache)
Key Findings1. Consistent speedup across all scenariosThe 2. Speedup scales with iterationsFor short text, the cache speedup improves from 2.6x (5 iterations) to 3.9x (20 iterations), confirming that the one-time cost of populating the cache is amortized over repeated layouts. 3. Dramatic memory savings on long textLong-text scenarios see the largest memory reduction: 72% fewer allocations. This is because the cache reuses the shaped glyph buffers instead of allocating new ones on each layout pass. For 20 iterations of long text, this saves 640 KB of managed allocations. 4. Varying-width scenario validates the core use caseThe "varying width" benchmark simulates the Measure → Arrange pattern where the same text is laid out multiple times with different width constraints. The cache delivers a 2.6x–3.6x speedup here, confirming it avoids reshaping when only the paragraph width changes. 5. GC pressure significantly reducedGen0 collections drop substantially with caching enabled:
ConclusionThe |
|
You can test this PR using the following package version. |
|
You can test this PR using the following package version. |
What does the pull request do?
Introduces a
TextRunCachethat preserves shaped text runs and bidi processing results acrossTextLayoutrebuilds. This avoids redundant HarfBuzz shaping and UAX#9 bidi processing when only the paragraph width constraint changes — the common case during the Measure Arrange cycle inTextBlockandTextPresenter.What is the current behavior?
TextBlock.MeasureOverridecreates aTextLayout(which shapes all text viaShapeTextRuns), thenArrangeOverridedisposes it and creates a new one with the final constraint, re-running all shaping and bidi work from scratch. For wrapping text,WrappingTextLineBreakalready caches remaining shaped runs between lines within a single layout pass, but nothing is preserved across layout rebuilds.What is the updated/expected behavior with this PR?
On the first
FormatLinecall (cache miss), shaped runs are stored in theTextRunCachekeyed byfirstTextSourceIndex. On subsequent calls with the same text source index (cache hit), the cachedShapedTextRundata is reusedFetchTextRunsandShapeTextRunsare skipped entirely. Line breaking / wrapping still runs against the new paragraph width, so layout adapts to constraint changes without re-shaping.TextBlockandTextPresentermanage the cache lifecycle:Text,FontFamily,FontSize,FontWeight,FontStyle,FontStretch,FlowDirection,LetterSpacing,FontFeatures,TextDecorations,Foreground,Inlines) invalidate the cache.TextWrapping,TextTrimming,TextAlignment,Padding,LineHeight,MaxLines) preserve the cache.How was the solution implemented (if it's not obvious)?
The cache stores the full
ShapedTextRun[]output ofShapeTextRunsper paragraph (keyed by text source index). On cache hit, freshShapedTextRunwrappers are created around non-owningShapedBufferviews (via the internalShapedBufferconstructor that doesn't hold_rentedBuffer). This ensures that whenTextLineImpl.Dispose()disposes its runs, the cached buffers remain valid - only the cache itself disposes the original owning buffers onInvalidate()/Dispose().TextFormatter.FormatLinegains a newvirtualoverload acceptingTextRunCache?, keeping the originalabstractsignature intact.TextFormatterImploverrides both - the original delegates to the new one withnull.Checklist
Breaking changes
None. The original
TextFormatter.FormatLineabstract signature is unchanged. The cache-aware variant is a separatevirtualoverload with a default implementation that delegates to the original.Obsoletions / Deprecations
None.
Fixed issues