Skip to content

Implement TextRunCache#21030

Merged
MrJul merged 1 commit into
AvaloniaUI:masterfrom
Gillibald:textRunCache
Apr 15, 2026
Merged

Implement TextRunCache#21030
MrJul merged 1 commit into
AvaloniaUI:masterfrom
Gillibald:textRunCache

Conversation

@Gillibald

@Gillibald Gillibald commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Introduces a TextRunCache that preserves shaped text runs and bidi processing results across TextLayout rebuilds. 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 in TextBlock and TextPresenter.

What is the current behavior?

TextBlock.MeasureOverride creates a TextLayout (which shapes all text via ShapeTextRuns), then ArrangeOverride disposes it and creates a new one with the final constraint, re-running all shaping and bidi work from scratch. For wrapping text, WrappingTextLineBreak already 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 FormatLine call (cache miss), shaped runs are stored in the TextRunCache keyed by firstTextSourceIndex. On subsequent calls with the same text source index (cache hit), the cached ShapedTextRun data is reused FetchTextRuns and ShapeTextRuns are skipped entirely. Line breaking / wrapping still runs against the new paragraph width, so layout adapts to constraint changes without re-shaping.

TextBlock and TextPresenter manage the cache lifecycle:

  • Properties that affect shaping (Text, FontFamily, FontSize, FontWeight, FontStyle, FontStretch, FlowDirection, LetterSpacing, FontFeatures, TextDecorations, Foreground, Inlines) invalidate the cache.
  • Properties that only affect layout (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 of ShapeTextRuns per paragraph (keyed by text source index). On cache hit, fresh ShapedTextRun wrappers are created around non-owning ShapedBuffer views (via the internal ShapedBuffer constructor that doesn't hold _rentedBuffer). This ensures that when TextLineImpl.Dispose() disposes its runs, the cached buffers remain valid - only the cache itself disposes the original owning buffers on Invalidate() / Dispose().

TextFormatter.FormatLine gains a new virtual overload accepting TextRunCache?, keeping the original abstract signature intact. TextFormatterImpl overrides both - the original delegates to the new one with null.

Checklist

Breaking changes

None. The original TextFormatter.FormatLine abstract signature is unchanged. The cache-aware variant is a separate virtual overload with a default implementation that delegates to the original.

Obsoletions / Deprecations

None.

Fixed issues

@Gillibald

Copy link
Copy Markdown
Contributor Author

API diff between 12.0.999-cibuild0064200-alpha and 12.0.999

Avalonia.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);
+     }
  }

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0064226-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@Gillibald Gillibald changed the title [WIP] Implement TextRunCache Implement TextRunCache Mar 30, 2026
@Gillibald

Copy link
Copy Markdown
Contributor Author

TextRunCache Benchmark Analysis

Date: 2026-04-02
System: 12th Gen Intel Core i7-12700K 3.60GHz, 20 logical / 12 physical cores
Runtime: .NET 10.0.5, X64 RyuJIT x86-64-v3

Raw Results

Method Iterations Mean Ratio Allocated Alloc Ratio
LayoutWithoutCache_Short 5 99.91 us 1.00 23.2 KB 1.00
LayoutWithCache_Short 5 38.96 us 0.39 13.59 KB 0.59
LayoutWithoutCache_Long 5 1,186.66 us 11.88 221.37 KB 9.54
LayoutWithCache_Long 5 434.05 us 4.34 61.82 KB 2.66
LayoutWithoutCache_VaryingWidth 5 1,206.90 us 12.08 244.91 KB 10.56
LayoutWithCache_VaryingWidth 5 460.20 us 4.61 85.37 KB 3.68
LayoutWithoutCache_Short 20 389.30 us 1.00 92.81 KB 1.00
LayoutWithCache_Short 20 100.47 us 0.26 52.5 KB 0.57
LayoutWithoutCache_Long 20 4,706.60 us 12.09 885.47 KB 9.54
LayoutWithCache_Long 20 2,289.46 us 5.88 245.41 KB 2.64
LayoutWithoutCache_VaryingWidth 20 8,796.11 us 22.60 903.66 KB 9.74
LayoutWithCache_VaryingWidth 20 2,451.70 us 6.30 263.6 KB 2.84

Speed Improvements (Cache vs No Cache)

Scenario Iterations Without Cache With Cache Speedup
Short text 5 99.91 us 38.96 us 2.6x
Short text 20 389.30 us 100.47 us 3.9x
Long text 5 1,186.66 us 434.05 us 2.7x
Long text 20 4,706.60 us 2,289.46 us 2.1x
Varying width 5 1,206.90 us 460.20 us 2.6x
Varying width 20 8,796.11 us 2,451.70 us 3.6x

Memory Reduction (Cache vs No Cache)

Scenario Iterations Without Cache With Cache Reduction
Short text 5 23.20 KB 13.59 KB 41%
Short text 20 92.81 KB 52.50 KB 43%
Long text 5 221.37 KB 61.82 KB 72%
Long text 20 885.47 KB 245.41 KB 72%
Varying width 5 244.91 KB 85.37 KB 65%
Varying width 20 903.66 KB 263.60 KB 71%

Key Findings

1. Consistent speedup across all scenarios

The TextRunCache delivers a 2.1x–3.9x speedup across every tested scenario. The cache avoids redundant text shaping by reusing previously shaped TextRun results when the same text is laid out again.

2. Speedup scales with iterations

For 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 text

Long-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 case

The "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 reduced

Gen0 collections drop substantially with caching enabled:

  • Long text (20 iter): 62.5 → 15.6 Gen0 collections per 1000 ops (75% reduction)
  • Varying width (20 iter): 62.5 → 15.6 Gen0 collections per 1000 ops (75% reduction)

Conclusion

The TextRunCache provides a substantial and consistent improvement to TextLayout performance. Reusing a cache across multiple layouts of the same text content yields ~2–4x faster layout and 40–72% lower memory allocations, with the benefits increasing for longer text and more iterations. This directly benefits controls like TextBlock and TextPresenter that recreate layouts during measure/arrange cycles.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0064453-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Comment thread src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
Comment thread src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
Comment thread src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs Outdated
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0064773-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@MrJul MrJul added this pull request to the merge queue Apr 15, 2026
Merged via the queue into AvaloniaUI:master with commit f41f97e Apr 15, 2026
11 checks passed
@MrJul MrJul added the backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch label May 28, 2026
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request May 28, 2026
@MrJul MrJul added backported-12.0.x and removed backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch labels May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants