Implement TextRunCache by Gillibald · Pull Request #21030 · AvaloniaUI/Avalonia (original) (raw)

@Gillibald

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:

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

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 {

@avaloniaui-bot

@Gillibald Gillibald changed the title[WIP] Implement TextRunCache Implement TextRunCache

Mar 30, 2026

@Gillibald

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:

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

MrJul

@Gillibald

@avaloniaui-bot

MrJul

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

May 28, 2026

@Gillibald @MrJul

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 }})