Skip to content

Commit f5045cd

Browse files
Optimize text layout hot paths and caching
Improves rendering and layout performance by removing per-glyph allocations and reusing computed data. TextBlock now caches the most recent line-broken layout and lazily cached bounds with thread-safe publication, FontDescription precomputes the invariant uppercase font name once, and several hot-path foreach loops over interface-typed metric/break collections were replaced with index-based loops to avoid heap enumerators. The changeset also adds repository-wide AI agent instruction files, updates binary file patterns in .gitattributes, and advances the shared-infrastructure submodule.
1 parent 30b77af commit f5045cd

13 files changed

Lines changed: 265 additions & 32 deletions

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,15 @@
8484
# treat as binary
8585
###############################################################################
8686
*.basis binary
87+
*.a binary
8788
*.dll binary
89+
*.dylib binary
8890
*.exe binary
8991
*.pdf binary
9092
*.ppt binary
9193
*.pptx binary
9294
*.pvr binary
95+
*.so binary
9396
*.snk binary
9497
*.xls binary
9598
*.xlsx binary

.github/copilot-instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# GitHub Copilot Instructions
2+
3+
Read and follow [AGENTS.md](../AGENTS.md) as the repository-wide source of coding, performance, and verification requirements. Prefer existing local patterns and repository configuration whenever generated code or suggestions are accepted.

AGENTS.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Six Labors AI Coding Guidelines
2+
3+
These instructions apply to the entire repository. More-specific `AGENTS.md` files may add to or override them for their directory tree.
4+
5+
## Working Practices
6+
7+
- Inspect the relevant implementation, tests, benchmarks, project files, and nearby code before proposing or making changes. Do not infer current behavior when the source is available.
8+
- Make the smallest complete change that solves the requested problem. Avoid unrelated cleanup, speculative abstractions, and formatting churn.
9+
- Match established architecture, naming, formatting, documentation, and test patterns. Treat `.editorconfig`, analyzers, and repository build settings as authoritative.
10+
- Preserve public API and observable behavior unless the task explicitly requires a change. Public API documentation must describe observable behavior, not implementation details.
11+
- Do not use reflection against built assemblies, ad hoc assembly loading, or temporary probe projects unless explicitly requested.
12+
- Build .NET projects in Release configuration unless explicitly instructed otherwise.
13+
14+
## Performance
15+
16+
- Treat throughput, latency, memory use, and binary size as design constraints, especially in pixel-processing, drawing, parsing, encoding, and other hot paths.
17+
- Avoid unnecessary allocations, copies, boxing, closures, interface dispatch, repeated enumeration, and extra passes over data.
18+
- Reuse the repository's existing memory ownership, pooling, span, vectorization, and parallelization patterns. Do not introduce a new mechanism when an established one fits.
19+
- Keep hot loops simple and bounds-check-friendly. Hoist invariant work, preserve locality, and use the narrowest suitable data types without sacrificing correctness.
20+
- Do not trade correctness or maintainability for assumed speed. Support non-obvious optimizations with measurements or clear evidence, and add or update benchmarks when performance is the purpose of the change.
21+
- Consider all supported target frameworks and runtime capabilities. Do not regress fallback paths while optimizing newer runtimes.
22+
23+
## C# Conventions
24+
25+
- Follow the existing code around the change; local patterns take precedence over generic preferences.
26+
- Do not use `record` or `record struct` types.
27+
- Prefer established invariants over redundant guards. Validate at real external boundaries and do not add defensive checks for internally controlled states.
28+
- Do not extract single-use helpers merely to name a block. Extract only for genuine reuse, an established local pattern, or meaningful complexity reduction.
29+
- Add vertical whitespace after multi-line statements and declarations and between distinct logical stages. Never add trailing whitespace.
30+
- Document every method, constructor, and property, regardless of whether it is public, internal, protected, or private. Keep public API documentation limited to observable behavior; use private and internal documentation to capture the contract and intent needed to maintain the code.
31+
- Add inline comments throughout complex code. Explain algorithms, formulas, invariants, ownership, compatibility behavior, and performance tradeoffs at the operations and decisions they govern. Explain why the code is shaped that way rather than narrating the syntax.
32+
- Document SIMD code especially thoroughly. Explain the vector layout, lane meaning, widening or narrowing, masks, shuffles, constants, alignment or remainder handling, supported instruction paths, scalar equivalence, and the reason each non-obvious operation is correct.
33+
- Write algorithm and SIMD comments for a maintainer who is unfamiliar with the implementation. The reader should not need to reconstruct intent from external documentation, issue history, or benchmark results.
34+
35+
## Verification
36+
37+
- Add or update focused tests when behavior changes, following the test framework and conventions already used by the project.
38+
- Never hack, weaken, skip, conditionally bypass, or otherwise manipulate a test to make it pass. Fix the production defect or the genuine test defect while preserving the test's intended coverage and sensitivity.
39+
- Do not update golden files, reference images, snapshots, baselines, or expected-output artifacts to resolve a test failure. Treat a mismatch as evidence to investigate and correct the implementation.
40+
- Run the narrowest relevant formatting, test, and Release build commands, then expand verification in proportion to the risk and scope of the change.
41+
- Report what changed, the verification performed, and any remaining risks or unverified assumptions.

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Claude Code Instructions
2+
3+
Read and follow [AGENTS.md](AGENTS.md) as the repository-wide source of coding, performance, and verification requirements. Apply any more-specific `AGENTS.md` or `CLAUDE.md` found below the files being changed.

GEMINI.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Gemini CLI Instructions
2+
3+
Read and follow [AGENTS.md](AGENTS.md) as the repository-wide source of coding, performance, and verification requirements. Apply any more-specific `AGENTS.md` or `GEMINI.md` found below the files being changed.

src/SixLabors.Fonts/FontDescription.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ internal FontDescription(NameTable nameTable, OS2Table? os2, HeadTable? head)
3333
this.FontNameInvariantCulture = this.FontName(CultureInfo.InvariantCulture);
3434
this.FontFamilyInvariantCulture = this.FontFamily(CultureInfo.InvariantCulture);
3535
this.FontSubFamilyNameInvariantCulture = this.FontSubFamilyName(CultureInfo.InvariantCulture);
36+
37+
// Upper-cased once here: glyph renderer parameters embed this name for every rendered
38+
// glyph, and per-glyph ToUpper calls would allocate a string per glyph per frame.
39+
this.FontNameUpperInvariantCulture = this.FontNameInvariantCulture.ToUpper(CultureInfo.InvariantCulture);
3640
}
3741

3842
/// <summary>
@@ -50,6 +54,12 @@ internal FontDescription(NameTable nameTable, OS2Table? os2, HeadTable? head)
5054
/// </summary>
5155
public string FontNameInvariantCulture { get; }
5256

57+
/// <summary>
58+
/// Gets the invariant-culture font name upper-cased once at construction, shared by every
59+
/// per-glyph renderer parameter instance.
60+
/// </summary>
61+
public string FontNameUpperInvariantCulture { get; }
62+
5363
/// <summary>
5464
/// Gets the name of the font family in the invariant culture.
5565
/// </summary>

src/SixLabors.Fonts/Rendering/GlyphRendererParameters.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ internal GlyphRendererParameters(
2222
GlyphLayoutMode layoutMode,
2323
int graphemeIndex)
2424
{
25-
this.Font = metrics.FontMetrics.Description.FontNameInvariantCulture?.ToUpper(CultureInfo.InvariantCulture) ?? string.Empty;
25+
// The upper-cased invariant name is computed once on the immutable description; doing it
26+
// here would allocate a string for every rendered glyph.
27+
this.Font = metrics.FontMetrics.Description.FontNameUpperInvariantCulture;
2628
this.FontStyle = metrics.FontMetrics.Description.Style;
2729
this.GlyphId = metrics.GlyphId;
2830
this.GraphemeIndex = graphemeIndex;

src/SixLabors.Fonts/TextBlock.cs

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,20 @@ namespace SixLabors.Fonts;
99
/// <summary>
1010
/// Represents text prepared for repeated line layout, measurement, and rendering.
1111
/// </summary>
12+
/// <remarks>
13+
/// Instances are safe to measure and render concurrently. The block retains the most recent
14+
/// line-broken layout; concurrent calls can only duplicate deterministic layout work, never
15+
/// observe partial state.
16+
/// </remarks>
1217
public sealed partial class TextBlock
1318
{
19+
/// <summary>
20+
/// The most recent line-broken layout, retained so repeated measurement and rendering at an
21+
/// unchanged wrapping length (the common redraw case) skip the full line-breaking rebuild.
22+
/// Published with volatile handoff; a concurrent race only duplicates deterministic work.
23+
/// </summary>
24+
private CachedTextLayout? cachedLayout;
25+
1426
/// <summary>
1527
/// Initializes a new instance of the <see cref="TextBlock"/> class.
1628
/// </summary>
@@ -67,10 +79,69 @@ public TextBlock(ReadOnlySpan<char> text, TextOptions options)
6779
/// <summary>
6880
/// Breaks this block into lines for the supplied wrapping length.
6981
/// </summary>
82+
/// <remarks>
83+
/// The result is retained for the most recent wrapping length, so repeated measurement and
84+
/// rendering at one length share a single line-breaking pass.
85+
/// </remarks>
7086
/// <param name="wrappingLength">The wrapping length in pixels. Use <c>-1</c> to disable wrapping.</param>
7187
/// <returns>The line-broken text box.</returns>
7288
internal TextBox BreakLines(float wrappingLength)
73-
=> TextLayout.BreakLines(this.LogicalLine, this.Options, wrappingLength);
89+
=> this.GetOrCreateLayout(wrappingLength).TextBox;
90+
91+
/// <summary>
92+
/// Gets the retained layout for the supplied wrapping length, or line-breaks and retains a new one.
93+
/// </summary>
94+
/// <remarks>
95+
/// The retained <see cref="TextBox"/> has its lazy aggregates computed before publication so
96+
/// concurrent readers never mutate shared state after the handoff. Concurrent calls with
97+
/// different wrapping lengths recompute deterministically; the last writer's layout stays
98+
/// retained.
99+
/// </remarks>
100+
/// <param name="wrappingLength">The wrapping length in pixels. Use <c>-1</c> to disable wrapping.</param>
101+
/// <returns>The layout for the supplied wrapping length.</returns>
102+
private CachedTextLayout GetOrCreateLayout(float wrappingLength)
103+
{
104+
CachedTextLayout? cached = Volatile.Read(ref this.cachedLayout);
105+
if (cached is not null && cached.WrappingLength == wrappingLength)
106+
{
107+
return cached;
108+
}
109+
110+
TextBox textBox = TextLayout.BreakLines(this.LogicalLine, this.Options, wrappingLength);
111+
112+
// Compute the box's memoized aggregates now: after publication the box is shared across
113+
// calls (and potentially threads), and lazy initialization on a shared instance would
114+
// race. The aggregate methods throw on an empty line list, so guard that case.
115+
if (textBox.TextLines.Count > 0)
116+
{
117+
_ = textBox.ScaledMaxAdvance();
118+
_ = textBox.ScaledMinY();
119+
}
120+
121+
_ = textBox.CountGlyphLayouts();
122+
123+
CachedTextLayout created = new(wrappingLength, textBox);
124+
Volatile.Write(ref this.cachedLayout, created);
125+
return created;
126+
}
127+
128+
/// <summary>
129+
/// Gets the rendered bounds for a retained layout, computing and publishing them on first use.
130+
/// </summary>
131+
/// <param name="layout">The retained layout.</param>
132+
/// <param name="wrappingLength">The wrapping length the layout was produced for.</param>
133+
/// <returns>The rendered glyph bounds.</returns>
134+
private FontRectangle GetOrComputeBounds(CachedTextLayout layout, float wrappingLength)
135+
{
136+
if (layout.TryGetBounds(out FontRectangle bounds))
137+
{
138+
return bounds;
139+
}
140+
141+
bounds = GetBounds(layout.TextBox, this.Options, wrappingLength);
142+
layout.SetBounds(in bounds);
143+
return bounds;
144+
}
74145

75146
/// <summary>
76147
/// Measures the full set of layout metrics for this block at the supplied wrapping length.
@@ -127,7 +198,10 @@ public FontRectangle MeasureAdvance(float wrappingLength)
127198
/// <param name="wrappingLength">The wrapping length in pixels. Use <c>-1</c> to disable wrapping.</param>
128199
/// <returns>The rendered glyph bounds.</returns>
129200
public FontRectangle MeasureBounds(float wrappingLength)
130-
=> GetBounds(this.BreakLines(wrappingLength), this.Options, wrappingLength);
201+
{
202+
CachedTextLayout layout = this.GetOrCreateLayout(wrappingLength);
203+
return this.GetOrComputeBounds(layout, wrappingLength);
204+
}
131205

132206
/// <summary>
133207
/// Measures the union of logical advance and rendered glyph bounds at the supplied wrapping length.
@@ -136,10 +210,11 @@ public FontRectangle MeasureBounds(float wrappingLength)
136210
/// <returns>The full renderable bounds.</returns>
137211
public FontRectangle MeasureRenderableBounds(float wrappingLength)
138212
{
139-
TextBox textBox = this.BreakLines(wrappingLength);
213+
CachedTextLayout layout = this.GetOrCreateLayout(wrappingLength);
214+
TextBox textBox = layout.TextBox;
140215
FontRectangle advance = GetAdvance(textBox, this.Options.Dpi, this.Options.LayoutMode.IsHorizontal());
141216
FontRectangle absoluteAdvance = new(this.Options.Origin.X, this.Options.Origin.Y, advance.Width, advance.Height);
142-
FontRectangle bounds = GetBounds(textBox, this.Options, wrappingLength);
217+
FontRectangle bounds = this.GetOrComputeBounds(layout, wrappingLength);
143218
return FontRectangle.Union(absoluteAdvance, bounds);
144219
}
145220

@@ -279,10 +354,12 @@ private LineLayout[] GetLineLayouts(TextBox textBox, float wrappingLength)
279354
/// <param name="wrappingLength">The wrapping length in pixels. Use <c>-1</c> to disable wrapping.</param>
280355
public void RenderTo(IGlyphRenderer renderer, float wrappingLength)
281356
{
282-
TextBox textBox = this.BreakLines(wrappingLength);
283-
FontRectangle rect = GetBounds(textBox, this.Options, wrappingLength);
357+
// Repeated rendering at one wrapping length reuses both the retained line-broken box and
358+
// its rendered bounds, so a warm call performs exactly one layout pass.
359+
CachedTextLayout layout = this.GetOrCreateLayout(wrappingLength);
360+
FontRectangle rect = this.GetOrComputeBounds(layout, wrappingLength);
284361

285-
RenderTo(renderer, textBox, this.Options, wrappingLength, rect);
362+
RenderTo(renderer, layout.TextBox, this.Options, wrappingLength, rect);
286363
}
287364

288365
/// <summary>
@@ -603,4 +680,72 @@ private static FontRectangle GetAdvance(TextBox textBox, float dpi, bool isHoriz
603680

604681
return new FontRectangle(0, 0, verticalWidth * dpi, verticalHeight * dpi);
605682
}
683+
684+
/// <summary>
685+
/// Retains one line-broken layout and its lazily computed rendered bounds.
686+
/// </summary>
687+
private sealed class CachedTextLayout
688+
{
689+
/// <summary>
690+
/// The rendered glyph bounds; valid only after <see cref="hasBounds"/> is set.
691+
/// </summary>
692+
private FontRectangle bounds;
693+
694+
/// <summary>
695+
/// Release/acquire gate for <see cref="bounds"/>: the volatile write in
696+
/// <see cref="SetBounds"/> publishes the fully written rectangle to readers.
697+
/// </summary>
698+
private volatile bool hasBounds;
699+
700+
/// <summary>
701+
/// Initializes a new instance of the <see cref="CachedTextLayout"/> class.
702+
/// </summary>
703+
/// <param name="wrappingLength">The wrapping length the layout was produced for.</param>
704+
/// <param name="textBox">The line-broken text box.</param>
705+
public CachedTextLayout(float wrappingLength, TextBox textBox)
706+
{
707+
this.WrappingLength = wrappingLength;
708+
this.TextBox = textBox;
709+
}
710+
711+
/// <summary>
712+
/// Gets the wrapping length the layout was produced for.
713+
/// </summary>
714+
public float WrappingLength { get; }
715+
716+
/// <summary>
717+
/// Gets the line-broken text box.
718+
/// </summary>
719+
public TextBox TextBox { get; }
720+
721+
/// <summary>
722+
/// Tries to read the previously published rendered bounds.
723+
/// </summary>
724+
/// <param name="value">Receives the rendered bounds when available.</param>
725+
/// <returns><see langword="true"/> when bounds have been published.</returns>
726+
public bool TryGetBounds(out FontRectangle value)
727+
{
728+
if (this.hasBounds)
729+
{
730+
value = this.bounds;
731+
return true;
732+
}
733+
734+
value = default;
735+
return false;
736+
}
737+
738+
/// <summary>
739+
/// Publishes the rendered bounds for this layout.
740+
/// </summary>
741+
/// <param name="value">The rendered bounds.</param>
742+
public void SetBounds(in FontRectangle value)
743+
{
744+
// The field write precedes the volatile flag write, so a reader observing the flag
745+
// also observes the completed rectangle. Concurrent writers store the same
746+
// deterministic value.
747+
this.bounds = value;
748+
this.hasBounds = true;
749+
}
750+
}
606751
}

0 commit comments

Comments
 (0)