Skip to content

Commit d40e31f

Browse files
GillibaldMrJul
andcommitted
[Text] Unicode and text layout optimizations (AvaloniaUI#21400)
* perf(text): O(1) width queries on ShapedBuffer, cached LB-class lookup - ShapedBuffer: lazy, pooled cluster-prefix cache shared across Split; adds TotalGlyphAdvance / MeasureCharactersThatFit. - GlyphRun/TextFormatterImpl/Skia GlyphRunImpl: read the cache, drop duplicate scans; SplitTextRuns now returns firstLength via out. - LineBreakEnumerator: cache Next/PreviousClass per advance; remove unused LineBreakPairTable. - Tests + benchmarks for the new paths and a dotnet-trace harness. ~1.32x faster / -5% alloc on the emoji-wrap micro-benchmark; no observable behaviour or public API change. * perf(TextFormatting): inline LineBreakEnumerator rule dispatch Replace the BreakUnitDelegate[] s_rules array dispatch in LineBreakEnumerator.ExecuteRules with a sequence of direct static calls and a `goto Done` early-exit. This removes 42 indirect calls per codepoint and lets the JIT reason across rule boundaries, which in turn makes [MethodImpl(AggressiveInlining)] meaningful — JIT cannot inline through delegate.Invoke, so the attribute was a no-op in the previous shape. Selectively apply AggressiveInlining to the small single-condition rules (LB03, LB04, LB06, LB07, LB08a, LB11–LB15d, LB18, LB20, LB21b, LB22, LB29, LB31) and let the JIT decide on the larger ones to avoid bloating the merged ExecuteRules. The static BreakUnitDelegate[] s_rules array is removed. Benchmarks (BDN default job, --inProcess, N=13–22, rel. StdDev <1.5%): UnicodeBreakEnumeratorBenchmark.LineBreakEnumerator_Sequence Ascii 154.6 µs -> 24.84 µs (6.22x) Bmp 190.2 µs -> 32.81 µs (5.80x) Supplementary 227.6 µs -> 37.56 µs (6.06x) TextLayoutProfile.BuildEmojisWrapped Before (branch, pre-inline) 804.5 µs / 570.15 KB After (branch, post-inline) 547.0 µs / 570.15 KB (-32.0%) vs upstream/master (1061 µs) -> ~1.94x total speedup No allocation change; CPU-only dispatch reshape. All LineBreakEnumerator unit tests pass (5/5). * Refactor ShapedBuffer to share pool storage via IRef + generation counter Wrap the ArrayPool-rented glyph and cluster-cache arrays in a small PooledArray<T> disposable and expose them through IRef<T>. Split children and WithBidiLevel aliases now Clone() the refs instead of borrowing raw pool arrays, so the backing storage survives until every sibling has been disposed - eliminating the UAF risk that existed when a parent was disposed before its children. Add a per-glyph-holder generation counter (Volatile.Read / Interlocked .Increment). The indexer setter bumps the counter on every write, and EnsureClusterCache compares its recorded generation against the holder's current value, rebuilding on mismatch. This lets us drop the previous "no mutation after Split/WithBidiLevel" contract: mutations performed through any sibling now propagate to the others' caches transparently. Dispose is made idempotent via a _disposed guard so overlapping cache eviction and TextLine teardown only release the IRefs once. Adds ShapedBufferSharedStorageTests covering sibling lifetime, Dispose idempotency, and generation-driven cache invalidation across Split children and WithBidiLevel aliases. * test(ShapedBuffer): cover cluster-cache sharing across Split/WithBidiLevel aliases Add a regression test hook (`ClusterPrefix`) exposing the backing cluster-prefix array reference, plus two tests that mutate a parent buffer before aliasing it and assert the alias reuses the parent's pooled prefix array instead of rebuilding. Guards against forgetting to propagate `_cacheGeneration` to alias buffers, which would silently defeat the cached-split fast path. * Correctly use MathUtilities.LessThanOrClose --------- Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
1 parent 1a43ec2 commit d40e31f

23 files changed

Lines changed: 3462 additions & 356 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,5 @@ src/Browser/Avalonia.Browser/staticwebassets
224224
# Claude agent worktrees
225225
.claude/worktrees/
226226
/.claude/settings.local.json
227+
/planning
228+
BenchmarkDotNet.Artifacts.*

src/Avalonia.Base/Media/GlyphRun.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -684,15 +684,26 @@ private GlyphRunMetrics CreateGlyphRunMetrics()
684684
}
685685

686686
var height = GlyphTypeface.Metrics.LineSpacing * Scale;
687-
var widthIncludingTrailingWhitespace = 0d;
688687

689688
var trailingWhitespaceLength = GetTrailingWhitespaceLength(isReversed, out var newLineLength, out var glyphCount);
690689

691-
for (var index = 0; index < _glyphInfos.Count; index++)
690+
// when our glyph source is a ShapedBuffer (the common case every
691+
// shape result flows through one), it already maintains a cluster-width
692+
// prefix sum we can read in O(1) instead of summing all advances here.
693+
double widthIncludingTrailingWhitespace;
694+
695+
if (_glyphInfos is TextFormatting.ShapedBuffer shapedBuffer)
696+
{
697+
widthIncludingTrailingWhitespace = shapedBuffer.TotalGlyphAdvance;
698+
}
699+
else
692700
{
693-
var advance = _glyphInfos[index].GlyphAdvance;
701+
widthIncludingTrailingWhitespace = 0d;
694702

695-
widthIncludingTrailingWhitespace += advance;
703+
for (var index = 0; index < _glyphInfos.Count; index++)
704+
{
705+
widthIncludingTrailingWhitespace += _glyphInfos[index].GlyphAdvance;
706+
}
696707
}
697708

698709
var width = widthIncludingTrailingWhitespace;

src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs

Lines changed: 545 additions & 14 deletions
Large diffs are not rendered by default.

src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

Lines changed: 109 additions & 87 deletions
Large diffs are not rendered by default.

src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs

Lines changed: 202 additions & 150 deletions
Large diffs are not rendered by default.

src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakPairTable.cs

Lines changed: 0 additions & 74 deletions
This file was deleted.

src/Skia/Avalonia.Skia/GlyphRunImpl.cs

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System;
22
using System.Buffers;
33
using System.Collections.Generic;
4-
using System.Threading;
54
using Avalonia.Media;
65
using Avalonia.Media.TextFormatting;
76
using Avalonia.Platform;
@@ -39,18 +38,14 @@ public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize,
3938
_glyphIndices = new ushort[count];
4039
_glyphPositions = new SKPoint[count];
4140

42-
var currentX = 0.0;
43-
41+
// GetGlyphWidths needs _glyphIndices populated before the
42+
// per-glyph bounds can be fetched, so this walk has to come
43+
// first. It deliberately does no other work — positions and
44+
// runBounds are built together in the fused walk below, using
45+
// a single currentX accumulator.
4446
for (int i = 0; i < count; i++)
4547
{
46-
var glyphInfo = glyphInfos[i];
47-
var offset = glyphInfo.GlyphOffset;
48-
49-
_glyphIndices[i] = glyphInfo.GlyphIndex;
50-
51-
_glyphPositions[i] = new SKPoint((float)(currentX + offset.X), (float)offset.Y);
52-
53-
currentX += glyphInfos[i].GlyphAdvance;
48+
_glyphIndices[i] = glyphInfos[i].GlyphIndex;
5449
}
5550

5651
// Ideally the requested edging should be passed to the glyph run.
@@ -67,26 +62,39 @@ public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize,
6762

6863
using var font = CreateFont(defaultTextOptions);
6964

70-
var runBounds = new Rect();
7165
var glyphBounds = ArrayPool<SKRect>.Shared.Rent(count);
7266

73-
font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count));
67+
try
68+
{
69+
font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count));
7470

75-
currentX = 0;
71+
// build _glyphPositions and union runBounds in a
72+
// single pass. Replaces the previous two separate walks (each
73+
// maintaining its own currentX) each glyphInfo is read once,
74+
// and one accumulator covers both outputs.
75+
var currentX = 0.0;
76+
var runBounds = new Rect();
7677

77-
for (var i = 0; i < count; i++)
78-
{
79-
var gBounds = glyphBounds[i];
80-
var advance = glyphInfos[i].GlyphAdvance;
78+
for (int i = 0; i < count; i++)
79+
{
80+
var glyphInfo = glyphInfos[i];
81+
var offset = glyphInfo.GlyphOffset;
82+
var gBounds = glyphBounds[i];
8183

82-
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
84+
_glyphPositions[i] = new SKPoint((float)(currentX + offset.X), (float)offset.Y);
8385

84-
currentX += advance;
85-
}
86-
ArrayPool<SKRect>.Shared.Return(glyphBounds);
86+
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
87+
88+
currentX += glyphInfo.GlyphAdvance;
89+
}
8790

88-
BaselineOrigin = baselineOrigin;
89-
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
91+
BaselineOrigin = baselineOrigin;
92+
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
93+
}
94+
finally
95+
{
96+
ArrayPool<SKRect>.Shared.Return(glyphBounds);
97+
}
9098
}
9199

92100
public double FontRenderingEmSize { get; }

0 commit comments

Comments
 (0)