9.2 KiB
Heap Allocation Analysis for Terminal.Gui
Executive Summary
This document provides a comprehensive analysis of intermediate heap allocations in Terminal.Gui, focusing on the TextFormatter and LineCanvas classes as reported in the issue.
Severity Assessment: HIGH IMPACT
The allocation issues identified are significant performance concerns that affect:
- Every frame redraw in UI scenarios
- Any time-based updates (progress bars, timers, clocks)
- Text rendering operations
- Border and line drawing operations
Key Findings
1. TextFormatter Class (Terminal.Gui/Text/TextFormatter.cs)
Critical Allocation Hotspots
Location: Line 126 (in Draw method)
string[] graphemes = GraphemeHelper.GetGraphemes (strings).ToArray ();
- Frequency: Every time Draw is called (potentially 60+ times per second during animations)
- Impact: Allocates a new string array for every line being drawn
- Called from: View.Drawing.cs, Border.cs, TextField.cs, and other UI components
Location: Line 934 (in GetDrawRegion method)
string [] graphemes = GraphemeHelper.GetGraphemes (strings).ToArray ();
- Frequency: Every time region calculation is needed
- Impact: Similar allocation for grapheme arrays
Additional Allocation Points:
- Line 1336:
List<string> graphemes = GraphemeHelper.GetGraphemes (text).ToList ();inSplitNewLine - Line 1407:
string [] graphemes = GraphemeHelper.GetGraphemes (text).ToArray ();inClipOrPad - Line 1460:
List<string> graphemes = GraphemeHelper.GetGraphemes (StripCRLF (text)).ToList ();inWordWrapText - Line 1726:
List<string> graphemes = GraphemeHelper.GetGraphemes (text).ToList ();inClipAndJustify - Line 2191:
string [] graphemes = GraphemeHelper.GetGraphemes (text).ToArray ();inGetSumMaxCharWidth - Line 2300:
string [] graphemes = GraphemeHelper.GetGraphemes (lines [lineIdx]).ToArray ();inGetMaxColsForWidth
Total Count: 9 distinct allocation points in TextFormatter alone
Why This Matters
The Draw method is called:
- On every frame update for animated content
- When any view needs to redraw its text
- During progress bar updates (the example mentioned in the issue)
- For real-time displays (clocks, status bars)
With a typical progress bar updating at 10-30 times per second, and potentially multiple text elements on screen, this can result in hundreds to thousands of allocations per second.
2. LineCanvas Class (Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs)
Critical Allocation Hotspot
Location: Lines 219-222 (in GetMap(Rectangle inArea) method)
IntersectionDefinition [] intersects = _lines
.Select (l => l.Intersects (x, y))
.OfType<IntersectionDefinition> ()
.ToArray ();
- Frequency: Once per pixel in the area (nested loop over x and y)
- Impact: EXTREMELY HIGH - allocates array for every single pixel being evaluated
- Example: A 80x24 terminal window border = 1,920 allocations per redraw
- Example: A 120x40 dialog with borders = 4,800 allocations per redraw
Good News
The GetCellMap() method (line 162) was already optimized:
List<IntersectionDefinition> intersectionsBufferList = [];
// ... reuses list with Clear() ...
ReadOnlySpan<IntersectionDefinition> intersects = CollectionsMarshal.AsSpan(intersectionsBufferList);
This is the correct pattern - reusing a buffer and using spans to avoid allocations.
3. Cell Class (Terminal.Gui/Drawing/Cell.cs)
Location: Line 30
if (GraphemeHelper.GetGraphemes(value).ToArray().Length > 1)
- Frequency: Every time Grapheme property is set
- Impact: Moderate - validation code
4. GraphemeHelper Pattern
The core issue is that GraphemeHelper.GetGraphemes() returns an IEnumerable<string>, which is then immediately materialized to arrays or lists. This pattern appears throughout the codebase.
Root Cause Analysis
TextFormatter Allocations
The fundamental issue is the design pattern:
GetGraphemes()returnsIEnumerable<string>(lazy enumeration)- Code immediately calls
.ToArray()or.ToList()to materialize it - This happens on every draw call, creating garbage
LineCanvas Allocations
The GetMap(Rectangle inArea) method has a particularly problematic nested loop structure:
- Outer loop: Y coordinates
- Inner loop: X coordinates
- Inside inner loop: LINQ query with
.ToArray()allocation
This is a classic O(n²) allocation problem where the allocation count grows quadratically with area size.
Performance Impact Estimation
TextFormatter in Progress Demo
Assuming:
- Progress bar updates 20 times/second
- Each update redraws the bar (1 line) and percentage text (1 line)
- Each line calls
Draw()which allocates an array
Result: 40 array allocations per second, just for the progress bar
Add a clock display updating once per second, status messages, etc., and we easily reach hundreds of allocations per second in a moderately complex UI.
LineCanvas in Border Drawing
A typical dialog window:
- 100x30 character area
- Border needs to evaluate 2×(100+30) = 260 pixels for the border
- Each pixel: 1 array allocation
Result: 260 allocations per border redraw
If the dialog is redrawn 10 times per second (e.g., with animated content inside), that's 2,600 allocations per second just for one border.
Comparison to v2_develop Branch
The issue mentions that allocations "increased drastically" on the v2_develop branch, particularly from LineCanvas. This is consistent with the findings:
- GetMap(Rectangle) method allocates per-pixel
- If border drawing or line canvas usage increased in v2, this would multiply the allocation impact
Memory Allocation Types
The allocations fall into several categories:
- String Arrays:
string[]from.ToArray() - String Lists:
List<string>from.ToList() - LINQ Enumerable Objects: Intermediate enumerables in LINQ chains
- Dictionary/Collection Allocations: Less critical but still present
GC Impact
With Gen0 collections potentially happening multiple times per second due to these allocations:
- Pause times: GC pauses affect UI responsiveness
- CPU overhead: GC work consumes CPU that could render content
- Memory pressure: Constant allocation/collection cycle
- Cache pollution: Reduces cache effectiveness
Recommended Solutions (High-Level)
For TextFormatter
- Use ArrayPool: Rent arrays from pool instead of allocating
- Use Span: Work with spans instead of materializing arrays
- Cache grapheme arrays: If text doesn't change, cache the split
- Lazy evaluation: Only materialize when truly needed
For LineCanvas
- Apply GetCellMap pattern to GetMap: Reuse buffer list, use spans
- Pool IntersectionDefinition arrays: Similar to GetCellMap optimization
- Consider pixel-level caching: Cache intersection results for static lines
For GraphemeHelper
- Add GetGraphemesAsSpan: Return
ReadOnlySpan<string>variant where possible - Add TryGetGraphemeCount: Count without allocation for validation
- Consider string pooling: Pool common grapheme strings
Measurement Recommendations
To quantify the impact:
- Add BenchmarkDotNet tests: Measure allocations for typical scenarios
- Profile with dotnet-trace: Capture allocation profiles during Progress demo
- Memory profiler: Use Visual Studio or JetBrains dotMemory
Severity by Scenario
| Scenario | Severity | Reason |
|---|---|---|
| Static UI (no updates) | LOW | Allocations only on initial render |
| Progress bars / animations | CRITICAL | Continuous allocations 10-60 Hz |
| Text-heavy UI | HIGH | Many text elements = many allocations |
| Border-heavy UI | HIGH | Per-pixel allocations in LineCanvas |
| Simple forms | MEDIUM | Periodic allocations on interaction |
Conclusion
The heap allocation issue is real and significant, particularly for:
- Any time-based updates (progress bars, clocks, animations)
- Border/line-heavy UIs due to LineCanvas per-pixel allocations
- Text-heavy interfaces with frequent redraws
The good news is that the patterns for fixing this are well-established:
- ArrayPool usage
- Span adoption
- Buffer reuse (as demonstrated in GetCellMap)
The LineCanvas.GetMap() issue is particularly straightforward to fix by applying the same pattern already used in GetCellMap().
Files Requiring Changes
Priority order:
- Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs (GetMap method) - CRITICAL
- Terminal.Gui/Text/TextFormatter.cs (Draw method) - CRITICAL
- Terminal.Gui/Text/TextFormatter.cs (other allocation points) - HIGH
- Terminal.Gui/Drawing/Cell.cs (validation) - MEDIUM
- Terminal.Gui/Drawing/GraphemeHelper.cs (add span-based APIs) - MEDIUM
Next Steps
Based on this analysis, the recommendation is to:
- ✅ Acknowledge the issue is real and significant
- Fix the most critical issue: LineCanvas.GetMap() per-pixel allocations
- Fix TextFormatter.Draw() allocations
- Add benchmarks to measure improvement
- Consider broader architectural changes for grapheme handling
Analysis Date: 2025-12-03
Analyzed By: GitHub Copilot
Codebase: Terminal.Gui (v2_develop branch)