Implement TextRunCache by Gillibald · Pull Request #21030 · AvaloniaUI/Avalonia (original) (raw)
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
- Added unit tests (if possible)?
- Added XML documentation to any related classes?
- Consider submitting a PR to https://github.com/AvaloniaUI/avalonia-docs with user documentation
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
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);
}}
Gillibald changed the title
[WIP] Implement TextRunCache Implement TextRunCache
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request
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
[ Show hidden characters]({{ revealButtonHref }})